initial commit

This commit is contained in:
Hartmut Bauer committed 2026-10-09 17:13:39 +02:00
commit d84861949f
92 files changed
+8425

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
*.iml
.kotlin
.gradle
**/build/
xcuserdata
!src/**/build/
local.properties
.idea
.DS_Store
captures
.externalNativeBuild
.cxx
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
node_modules/
/div/
/server/h2.mv.db
+61
View File
@@ -0,0 +1,61 @@
# core
This project was created using the [Ktor Project Generator](https://start.ktor.io).
Here are some useful links to get you started:
* [Ktor Documentation](https://ktor.io/docs/home.html)
* [Ktor GitHub page](https://github.com/ktorio/ktor)
* [Ktor Slack chat](https://app.slack.com/client/T09229ZC6/C0A974TJ9). [Request an invite](https://surveys.jetbrains.com/s3/kotlin-slack-sign-up).
## Features
Here's a list of features included in this project:
| Name | Description |
|---------------------------------------------------------------------------------------|------------------------------------------------------------------------------------|
| [Status Pages](https://start.ktor.io/p/io.ktor/server-status-pages) | Provides exception handling for routes |
| [kotlinx.serialization](https://start.ktor.io/p/io.ktor/server-kotlinx-serialization) | Handles JSON serialization using kotlinx.serialization library |
| [Content Negotiation](https://start.ktor.io/p/io.ktor/server-content-negotiation) | Provides automatic content conversion according to Content-Type and Accept headers |
| [Authentication](https://start.ktor.io/p/io.ktor/server-auth) | Provides extension point for handling the Authorization header |
| [Static Content](https://start.ktor.io/p/io.ktor/server-static-content) | Serves static files from defined locations |
| [Caching Headers](https://start.ktor.io/p/io.ktor/server-caching-headers) | Provides options for responding with standard cache-control headers |
| [HTMX](https://start.ktor.io/p/io.ktor/server-htmx) | Includes HTMX for front-end scripting |
| [HTML DSL](https://start.ktor.io/p/org.jetbrains/server-html-dsl) | Generates HTML from Kotlin DSL |
| [PostgreSQL](https://start.ktor.io/p/org.jetbrains/server-postgres) | Adds Postgres database support |
| [Exposed](https://start.ktor.io/p/org.jetbrains/server-exposed) | Adds Exposed database to your application |
| [Dependency Injection](https://start.ktor.io/p/io.ktor/server-di) | Enables dependency injection for your server |
| [CSRF](https://start.ktor.io/p/io.ktor/server-csrf) | Cross-site request forgery mitigation |
| [Authentication OAuth](https://start.ktor.io/p/io.ktor/server-auth-oauth) | Handles OAuth Bearer authentication scheme |
| [Authentication JWT](https://start.ktor.io/p/io.ktor/server-auth-jwt) | Handles JSON Web Token (JWT) bearer authentication scheme |
| [Forwarded Headers](https://start.ktor.io/p/io.ktor/server-forwarded-header-support) | Allows handling proxied headers (X-Forwarded-*) |
| [Default Headers](https://start.ktor.io/p/io.ktor/server-default-headers) | Adds a default set of headers to HTTP responses |
| [CORS](https://start.ktor.io/p/io.ktor/server-cors) | Enables Cross-Origin Resource Sharing (CORS) |
| [Compression](https://start.ktor.io/p/io.ktor/server-compression) | Compresses responses using encoding algorithms like GZIP |
## Structure
This project includes the following modules:
| Path | Description |
|--------|-------------|
| | null |
| server | null |
| web | null |
## Building & Running
To build or run the project, use one of the following tasks:
| Task | Description |
|---------------------------|-------------------|
| `./gradlew :server:test` | Run the tests |
| `./gradlew :server:build` | Build the project |
| `./gradlew :server:run` | Run the server |
If the server starts successfully, you'll see the following output:
```
2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds.
2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080
```
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
+2
View File
@@ -0,0 +1,2 @@
kotlin.code.style=official
+42
View File
@@ -0,0 +1,42 @@
[versions]
appsrv = "0.2.0-alpha"
books = "0.2.0-alpha"
exposed = "1.5.0"
h2 = "2.4.240"
h2-r2dbc = "1.1.0.RELEASE"
hikari = "7.1.0"
html5Qrcode = "2.3.8"
htmx = "2.0.8"
jvm = "25"
koin = "4.2.2"
koinAnnotations = "2.3.1"
kotlin = "2.4.20"
kotlinx-html = "0.12.0"
kspPlugin = "2.3.12"
logback = "1.6.5}"
postgresql = "42.7.13"
[libraries]
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" }
exposed-r2dbc = { module = "org.jetbrains.exposed:exposed-r2dbc", version.ref = "exposed" }
h2database-h2 = { module = "com.h2database:h2", version.ref = "h2" }
h2database-r2dbc = { module = "io.r2dbc:r2dbc-h2", version.ref = "h2-r2dbc" }
hikaricp = { module = "com.zaxxer:HikariCP", version.ref = "hikari" }
koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" }
koin-logger = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" }
koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koinAnnotations" }
koin-ksp = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koinAnnotations" }
kotlinx-kotlinxHtml = { module = "org.jetbrains.kotlinx:kotlinx-html", version.ref = "kotlinx-html" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "kspPlugin" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
File diff suppressed because it is too large. Load diff
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
function switch_srv() {
local cmd=$1
ssh container@nathan systemctl --user $cmd kas_srv
return $?
}
function copy_to_server(){
rsync -rti --delete --stats server/build/install/server/* container@nathan:/var/containers/vol/kas/server
return $?
}
./gradlew :server:installDist && switch_srv stop && copy_to_server && switch_srv start
+126
View File
@@ -0,0 +1,126 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(ktorLibs.plugins.ktor)
alias(libs.plugins.kotlin.serialization)
// alias(libs.plugins.ksp)
alias(masterLibs.plugins.versionPlugin)
}
group = "net.keinekekse.app"
version = libs.versions.appsrv.get()
description = "Ktor Application Server"
extra["displayname"] = "Application Server"
extra["copyright"] = "Copyright (c) 2026 Hartmut Bauer"
versionConfig {
versionClassFile.set(layout.projectDirectory.file("src/main/kotlin/Version.kt"))
versionPackageName.set("net.keinekekse.app")
modules.set(
mapOf(
"books" to mapOf(
"version" to libs.versions.books.get(),
"displayName" to "Books-DB",
"description" to "Books + Comics Database"
)
)
)
}
application {
mainClass = "io.ktor.server.netty.EngineMain"
// java >= 24
// WARNING: A restricted method in java.lang.System has been called
// WARNING: java.lang.System::loadLibrary has been called by io.netty.util.internal.NativeLibraryUtil in an unnamed module
// (file:/Users/hb/.gradle/caches/modules-2/files-2.1/io.netty/netty-common/4.2.16.Final/c2bc7fa5acfa2afc777e944d84f0aa65559cc81e/netty-common-4.2.16.Final.jar)
// WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
// WARNING: Restricted methods will be blocked in a future release unless native access is enabled
applicationDefaultJvmArgs = listOf("--enable-native-access=ALL-UNNAMED")
}
val buildTailwind = tasks.register("buildTailwind", Exec::class) {
description = "build tailwind css file"
workingDir = rootProject.layout.projectDirectory.dir("tailwind").asFile
// direkt ins buildDir schreiben, weil Task processResources hier zu spät kommt
commandLine(
"/Users/hb/.npm-packages/bin/npx",
"@tailwindcss/cli",
"-i",
"./input.css",
"-o",
layout.buildDirectory.dir("resources/main/static").get()
.toString() + "/styles/tailwind.css",
"--minify"
)
}
val copyWebDistToServerResources = tasks.register("copyWebDistToServerResources", Copy::class) {
description = "copy web dist to server resources"
dependsOn(project(":web").tasks.named("jsBrowserDistribution"))
from(project(":web").layout.buildDirectory.dir("dist/js/productionExecutable"))
into(layout.buildDirectory.dir("resources/main/web"))
// from(rootProject.layout.projectDirectory.file("build/js/node_modules/bootstrap/dist/css/bootstrap.min.css"))
// into(layout.buildDirectory.dir("resources/main/web"))
}
tasks.named("processResources") {
dependsOn(buildTailwind)
dependsOn(copyWebDistToServerResources)
}
kotlin {
jvmToolchain(libs.versions.jvm.get().toInt())
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(libs.versions.jvm.get()))
}
}
dependencies {
implementation(project(":shared"))
implementation(masterLibs.utilLibrary)
implementation(ktorLibs.client.apache5)
implementation(ktorLibs.client.cio)
implementation(ktorLibs.client.core)
implementation(ktorLibs.htmx.html)
implementation(ktorLibs.serialization.kotlinx.json)
implementation(ktorLibs.server.auth)
implementation(ktorLibs.server.auth.oidc)
implementation(ktorLibs.server.auth.jwt)
implementation(ktorLibs.server.cachingHeaders)
implementation(ktorLibs.server.compression)
implementation(ktorLibs.server.config.yaml)
implementation(ktorLibs.server.contentNegotiation)
implementation(ktorLibs.server.core)
implementation(ktorLibs.server.cors)
implementation(ktorLibs.server.csrf)
implementation(ktorLibs.server.defaultHeaders)
implementation(ktorLibs.server.di)
implementation(ktorLibs.server.forwardedHeader)
implementation(ktorLibs.server.htmlBuilder)
implementation(ktorLibs.server.htmx)
implementation(ktorLibs.server.netty)
implementation(ktorLibs.server.statusPages)
implementation(ktorLibs.server.sessions)
implementation(libs.exposed.core)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.dao)
implementation(libs.exposed.r2dbc)
implementation(libs.h2database.h2)
implementation(libs.h2database.r2dbc)
implementation(libs.kotlinx.kotlinxHtml)
implementation(libs.logback.classic)
implementation(libs.postgresql)
implementation(libs.hikaricp)
// implementation(libs.koin.ktor)
// implementation(libs.koin.logger)
// implementation(libs.koin.annotations)
// ksp(libs.koin.ksp)
// testImplementation(kotlin("test"))
// testImplementation(ktorLibs.server.testHost)
}
+3
View File
@@ -0,0 +1,3 @@
#auto generated
#Fri Oct 09 01:13:32 CEST 2026
build.number=533
+32
View File
@@ -0,0 +1,32 @@
package net.keinekekse.app
import net.keinekekse.version.Version
object VERSION: Version {
override val version = "0.2.0-alpha"
override val fullVersion = "0.2.0-alpha+build.533"
override val major = 0
override val minor = 2
override val patch = 0
override val preRelease = "alpha"
override val buildMetadata = "build.533"
override val buildDate = "2026-10-08T23:13:32.459638Z"
override val displayName = "Application Server"
override val copyright = "Copyright (c) 2026 Hartmut Bauer"
override val name = "server"
override val description = "Ktor Application Server"
}
object VERSION_books: Version {
override val version = "0.2.0-alpha"
override val fullVersion = "0.2.0-alpha+build.533"
override val major = 0
override val minor = 2
override val patch = 0
override val preRelease = "alpha"
override val buildMetadata = "build.533"
override val buildDate = "2026-10-08T23:13:32.459742Z"
override val displayName = "Books-DB"
override val copyright = "Copyright (c) 2026 Hartmut Bauer"
override val name = "books"
override val description = "Books + Comics Database"
}
+18
View File
@@ -0,0 +1,18 @@
package net.keinekekse.app.books.config
import io.ktor.server.application.*
import io.ktor.server.plugins.di.*
import net.keinekekse.app.books.db.BookRepository
import net.keinekekse.app.books.db.BookRepositoryImpl
fun Application.configureBookDependencyInjection() {
log.info("Configuring book dependency injection")
val booksDb = configureBooksDatabase()
dependencies {
provide("booksDb") { booksDb }
provide<BookRepository> { BookRepositoryImpl(booksDb) }
}
}
@@ -0,0 +1,34 @@
package net.keinekekse.app.books.config
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.*
import net.keinekekse.app.core.config.getKeyFromConfiguration
import org.jetbrains.exposed.v1.core.DatabaseConfig
import org.jetbrains.exposed.v1.jdbc.Database
fun Application.configureBooksDatabase(): Database {
log.info("Configuring books database")
val config = HikariConfig().apply {
dataSourceClassName = "org.postgresql.ds.PGSimpleDataSource"
username = getKeyFromConfiguration("postgres.books_schema")
password = getKeyFromConfiguration("postgres.books_pwd")
addDataSourceProperty("serverName", getKeyFromConfiguration("postgres.host", "localhost"))
addDataSourceProperty("portNumber", getKeyFromConfiguration("postgres.port", "5432"))
addDataSourceProperty("databaseName", username)
maximumPoolSize = 6
isReadOnly = false
transactionIsolation = "TRANSACTION_SERIALIZABLE"
}
val dataSource = HikariDataSource(config)
return Database.connect(
datasource = dataSource,
databaseConfig = DatabaseConfig {
// set other parameters here
}
)
}
@@ -0,0 +1,177 @@
package net.keinekekse.app.books.config
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.oidc.*
import io.ktor.server.html.*
import io.ktor.server.htmx.*
import io.ktor.server.plugins.di.annotations.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.utils.io.*
import kotlinx.html.div
import kotlinx.html.p
import kotlinx.html.span
import net.keinekekse.app.books.db.BookRepository
import net.keinekekse.app.books.db.CalibreBookInfoRepositoryImpl
import net.keinekekse.app.books.scan.getCover
import net.keinekekse.app.books.scan.getIsbnFromCalibre
import net.keinekekse.app.books.ui.*
import net.keinekekse.app.core.auth.UserRoles
import net.keinekekse.app.core.config.KeycloakSession
import net.keinekekse.app.core.config.onUnauthorized
import org.jetbrains.exposed.v1.jdbc.Database
var thumbnailUrl: String? = null
@OptIn(ExperimentalKtorApi::class)
fun Application.configureRouting(
@Named("booksDb") db: Database,
booksRepo: BookRepository,
keycloakSession: KeycloakSession,
// resultCallback: (String, String) -> Unit,
) {
log.info("Configuring books routing")
routing {
authenticateWith(
keycloakSession,
roles = setOf(UserRoles.BOOKS_USER),
onUnauthorized = { onUnauthorized(call) }
) {
// Start
get(navBooks.second) {
log.info(" ### Books page requested")
val p = call.principal
call.application.log.info("AppPrincipal userId: ${p.userId}")
val pp = call.principal<OidcToken.Id>()
call.application.log.info("OidcToken.Id.Principal userId: ${pp?.userInfo?.preferredUsername}")
call.respondHtml {
testPage(db)
}
}
log.info(" ### Books page done")
// New/ Import
get(navBooksNew.second) {
val books = CalibreBookInfoRepositoryImpl(db).readAllBookInfo()
call.respondHtml {
importPage(books)
}
}
get(navBooksScan.second) {
call.respondHtml {
scanPage()
}
}
get("${navBooksScanEdit.second}/{id}") {
val id = call.parameters["id"]?.toInt() ?: 0
val bookInfo = CalibreBookInfoRepositoryImpl(db).readBookInfo(id)
call.respondHtml {
importEditPage(bookInfo, booksRepo)
}
}
delete("${navBooksScanEdit.second}/{id}") {
val id = call.parameters["id"]?.toInt() ?: 0
val biRepo = CalibreBookInfoRepositoryImpl(db)
val bookInfo = biRepo.readBookInfo(id)
if (bookInfo != null) {
biRepo.removeBookInfo(bookInfo.id)
}
val books = biRepo.readAllBookInfo()
call.respondHtml {
importPage(books)
}
}
hx.get("${navBooksScanFetch.second}/{isbn}") {
// val isbn = call.request.queryParameters["isbn"] ?: "" // "?isbn=..."
val isbn = call.parameters["isbn"] ?: ""
call.application.log.info("ISBN lookup for $isbn")
val bookInfo = getIsbnFromCalibre(isbn, db)
val image = getCover(isbn)
val dataUrl = embedImageURL(image)
call.respondHtmlPartial(status = HttpStatusCode.OK) {
isbnResultCallback(bookInfo, dataUrl)
}
}
// Edit
get(navBooksEdit.second) {
// TODO
}
hx.post(navBooksEdit.second) {
call.application.log.info("Edit book")
call.receiveParameters().forEach { param: String, value: List<String> ->
call.application.log.info("param: $param, value: $value")
}
call.respondHtmlPartial(HttpStatusCode.OK) {
p {
+"OK"
}
}
}
// hx.get("/isbnlookupreset") {
// call.respondHtmlPartial(HttpStatusCode.OK) {
// p { +"Bitte Barcode vor die Kamera halten..." }
// }
// }
// get("/testimage/{isbn}") {
// val isbn = call.parameters["isbn"] ?: ""
// val image = getCover(isbn)
// call.application.log.info("/testimage img len ${image.size}")
// call.respondBytes(
// bytes = image,
// contentType = ContentType.Image.JPEG,
// status = HttpStatusCode.OK
// )
// }
// "hx." lässt den Aufruf nur per HTMX zu. Der Pfad kann nicht
// direkt im Browser aufgerufen werden.
hx.get("/books/testdb") {
log.info(" ### DB test requested")
dbTest(db, booksRepo)
call.respondHtmlPartial(HttpStatusCode.OK) {
span {
+"DB created"
}
}
}
hx.post("/books/testform") {
log.info(" ### Form test requested")
val inp = call.receiveParameters()["testInput"]
log.info(" ### Form input: $inp")
// val formContent = call.receiveParameters()
// formContent.forEach { key, value -> // key -> input field name
// call.application.log.info(" ### Form key: $key") // == formContent[key]
// for (v in value) {
// call.application.log.info(" ### Form value(s): $v")
// }
// }
call.respondHtmlPartial(HttpStatusCode.OK) {
div {
+"form submitted: $inp"
}
}
}
// Test 401
authenticateWith(
keycloakSession,
roles = setOf(UserRoles.BOOKS_USER),
// onUnauthorized = { onUnauthorized(call) }
) {
get("/test") {
call.respondText("Test")
}
}
}
}
}
@@ -0,0 +1,18 @@
package net.keinekekse.app.books.db
import net.keinekekse.app.books.model.*
interface BookRepository {
suspend fun allBooks(): List<Book>
suspend fun bookById(id: Int): Book?
suspend fun booksBySeries(series: Series): List<Book>
suspend fun bookByTitle(title: String): List<Book>
suspend fun booksByArtist(artist: Artist): List<Book>
suspend fun addBook(book: Book)
suspend fun removeBook(id: Int): Boolean
suspend fun allPublishers(): List<Publisher>
suspend fun allArtists(): List<Artist>
suspend fun allSeries(): List<Series>
suspend fun allTags(): List<Tag>
}
@@ -0,0 +1,61 @@
package net.keinekekse.app.books.db
import net.keinekekse.app.books.db.schema.*
import net.keinekekse.app.books.model.*
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction
class BookRepositoryImpl(val booksDb: Database) : BookRepository {
override suspend fun allBooks(): List<Book> = suspendTransaction(booksDb) {
BookDAO.all().map(::bookDaoToModel)
}
override suspend fun bookById(id: Int): Book? = suspendTransaction(booksDb) {
BookDAO.findById(id)?.let { bookDaoToModel(it) }
}
// TODO noch zu testen
override suspend fun booksBySeries(series: Series): List<Book> = suspendTransaction(booksDb) {
BookDAO.find { BooksTable.series eq series.id }.map(::bookDaoToModel)
}
// TODO noch zu testen
override suspend fun bookByTitle(title: String): List<Book> = suspendTransaction(booksDb) {
val comp = "%$title%"
BookDAO.find { BooksTable.title like comp }.map(::bookDaoToModel)
}
// TODO noch zu testen
override suspend fun booksByArtist(artist: Artist): List<Book> = suspendTransaction(booksDb) {
BookDAO.find { (ArtistToBookTable.artistId eq artist.id) and (ArtistToBookTable.bookId eq BooksTable.id) }
.map(::bookDaoToModel)
}
override suspend fun addBook(book: Book) {
TODO("Not yet implemented")
}
override suspend fun removeBook(id: Int): Boolean {
TODO("Not yet implemented")
}
override suspend fun allPublishers(): List<Publisher> = suspendTransaction(booksDb) {
PublisherDAO.all().map(::publisherDaoToModel)
}
override suspend fun allArtists(): List<Artist> = suspendTransaction(booksDb) {
ArtistDAO.all().map(::artistDaoToModel)
}
override suspend fun allSeries(): List<Series> = suspendTransaction(booksDb) {
SeriesDAO.all().map(::seriesDaoToModel)
}
override suspend fun allTags(): List<Tag> = suspendTransaction(booksDb) {
TagsDAO.all().map(::tagDaoToModel)
}
}
@@ -0,0 +1,11 @@
package net.keinekekse.app.books.db
import net.keinekekse.app.books.db.schema.CalibreBookInfoDAO
import net.keinekekse.app.books.model.CalibreBookInfo
interface CalibreBookInfoRepository {
suspend fun addBookInfo(bookInfo: CalibreBookInfo): CalibreBookInfoDAO
suspend fun readAllBookInfo(): List<CalibreBookInfo>
suspend fun readBookInfo(id: Int): CalibreBookInfo?
suspend fun removeBookInfo(id: Int)
}
@@ -0,0 +1,31 @@
package net.keinekekse.app.books.db
import net.keinekekse.app.books.db.schema.CalibreBookInfoDAO
import net.keinekekse.app.books.db.schema.calibreBookInfoDaoToModel
import net.keinekekse.app.books.db.schema.modelToCalibreBookInfoDao
import net.keinekekse.app.books.model.CalibreBookInfo
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction
class CalibreBookInfoRepositoryImpl(val booksDb: Database) : CalibreBookInfoRepository {
override suspend fun addBookInfo(bookInfo: CalibreBookInfo) =
suspendTransaction(booksDb) {
modelToCalibreBookInfoDao(bookInfo)
}
override suspend fun readAllBookInfo(): List<CalibreBookInfo> =
suspendTransaction(booksDb) {
CalibreBookInfoDAO.all().map(::calibreBookInfoDaoToModel)
}
override suspend fun readBookInfo(id: Int): CalibreBookInfo? =
suspendTransaction(booksDb) {
CalibreBookInfoDAO.findById(id)?.let { calibreBookInfoDaoToModel(it) }
}
override suspend fun removeBookInfo(id: Int) =
suspendTransaction(booksDb) {
CalibreBookInfoDAO[id].delete()
}
}
@@ -0,0 +1,21 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.Artist
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object ArtistsTable : IntIdTable("artists") {
var name = varchar("name", 100).index()
var givenName = varchar("given_name", 100).index()
}
class ArtistDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<ArtistDAO>(ArtistsTable)
var name by ArtistsTable.name
var givenName by ArtistsTable.givenName
}
fun artistDaoToModel(dao: ArtistDAO) = Artist(dao.id.value, dao.name, dao.givenName)
@@ -0,0 +1,19 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.ArtistRole
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object ArtistRolesTable : IntIdTable("artist_roles") {
var role = varchar("role", 50).index()
}
class ArtistRoleDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<ArtistRoleDAO>(ArtistRolesTable)
var role by ArtistRolesTable.role
}
fun artistToRoleDaoToModel(dao: ArtistRoleDAO) = ArtistRole(dao.id.value, dao.role)
@@ -0,0 +1,20 @@
package net.keinekekse.app.books.db.schema
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object ArtistToBookTable : IntIdTable("artist_to_book") {
val artistId = reference("artist_id", ArtistsTable.id).index()
val bookId = reference("book_id", BooksTable.id).index()
val role = reference("role_id", ArtistRolesTable.id).index()
}
class ArtistToBookDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<ArtistToBookDAO>(ArtistToBookTable)
var artist by ArtistDAO referencedOn ArtistToBookTable.artistId
var book by BookDAO referencedOn ArtistToBookTable.bookId
var role by ArtistRoleDAO referencedOn ArtistToBookTable.role
}
@@ -0,0 +1,91 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.*
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object BooksTable : IntIdTable("books") {
var title = varchar("title", 150).index()
var subTitle = varchar("sub_title", 150).nullable()
var originalTitle = varchar("original_title", 150).nullable()
var year = integer("year")
var originalYear = integer("original_year").nullable()
var language = varchar("language", 2)
var isbn = varchar("isbn", 13).index()
var publisher = reference("publisher_id", PublisherTable.id)
var series = optReference("series_id", SeriesTable.id)
var seriesIndex = integer("seriesIndex").nullable()
var comments = varchar("comments", 255)
var parentBook = optReference("parent_book_id", id)
val cover = blob("cover").nullable()
}
class BookDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<BookDAO>(BooksTable)
var title by BooksTable.title
var subTitle by BooksTable.subTitle
var seriesIndex by BooksTable.seriesIndex
var originalTitle by BooksTable.originalTitle
var year by BooksTable.year
var originalYear by BooksTable.originalYear
var language: Languages by BooksTable.language.transform({ it.name }, { Languages.valueOf(it) })
var isbn by BooksTable.isbn
var publisher by PublisherDAO.Companion referencedOn BooksTable.publisher
var comments by BooksTable.comments
var cover by BooksTable.cover
var series by SeriesDAO.Companion optionalReferencedOn BooksTable.series
val artists by ArtistToBookDAO referrersOn ArtistToBookTable.bookId
val tags by TagsToBookDAO referrersOn TagsToBookTable.bookId
// Verweis auf Sammelband (falls dieses Buch ein Teilroman ist)
var parentBook by BookDAO optionalReferencedOn BooksTable.parentBook
// Enthaltene Einzelromane (falls dieses Buch der Sammelband ist)
val containedBooks by BookDAO optionalReferrersOn BooksTable.parentBook
}
fun bookDaoToModel(dao: BookDAO, includeChildren: Boolean = true): Book = Book(
dao.id.value,
dao.title,
dao.subTitle,
dao.originalTitle,
dao.year,
dao.originalYear,
dao.language,
dao.isbn,
publisher = Publisher(dao.publisher.id.value, dao.publisher.name),
dao.comments,
cover = dao.cover?.bytes ?: ByteArray(0),
series = if (dao.series != null) Series(dao.series!!.id.value, dao.series!!.name) else null,
seriesIndex = dao.seriesIndex,
dao.parentBook?.id?.value,
containedBooks = if (includeChildren) {
dao.containedBooks.map { bookDaoToModel(it, includeChildren = false) }
} else {
emptyList()
},
artists = dao.artists.map {
ArtistForBook(
Artist(it.artist.id.value, it.artist.name, it.artist.givenName),
ArtistRole(it.role.id.value, it.role.role)
)
},
tags = dao.tags.map {
Tag(it.tag.id.value, it.tag.tag)
},
)
fun emptyBook(title: String = "", year: Int = 0, isbn: String = ""): Book =
Book(
title = title,
year = year,
isbn = isbn,
publisher = Publisher(0, ""),
)
@@ -0,0 +1,65 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.CalibreBookInfo
import net.keinekekse.app.books.model.Languages
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.core.statements.api.ExposedBlob
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object CalibreBookInfoTable : IntIdTable("calibre_book_info") {
var title = varchar("title", 150)
val authors = varchar("authors", 200)
val publisher = varchar("publisher", 100)
val tags = varchar("tags", 200)
val language = varchar("language", 2)
val published = integer("published")
val identifiers = varchar("identifiers", 200)
val isbn = varchar("isbn", 20)
val comments = varchar("comments", 255)
val cover = blob("cover")
}
class CalibreBookInfoDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<CalibreBookInfoDAO>(CalibreBookInfoTable)
var title by CalibreBookInfoTable.title
var authors by CalibreBookInfoTable.authors
var publisher by CalibreBookInfoTable.publisher
var tags by CalibreBookInfoTable.tags
var language by CalibreBookInfoTable.language.transform({ it.name }, { Languages.valueOf(it) })
var published by CalibreBookInfoTable.published
var identifiers by CalibreBookInfoTable.identifiers
var isbn by CalibreBookInfoTable.isbn
var comments by CalibreBookInfoTable.comments
var cover by CalibreBookInfoTable.cover
}
fun calibreBookInfoDaoToModel(dao: CalibreBookInfoDAO): CalibreBookInfo = CalibreBookInfo(
dao.id.value,
dao.title,
dao.authors,
dao.publisher,
dao.tags,
dao.language,
dao.published,
dao.identifiers,
dao.isbn,
dao.comments,
dao.cover.bytes
)
fun modelToCalibreBookInfoDao(bookInfo: CalibreBookInfo): CalibreBookInfoDAO =
CalibreBookInfoDAO.new {
title = bookInfo.title.take(150)
authors = bookInfo.authors.take(200)
publisher = bookInfo.publisher.take(100)
tags = bookInfo.tags.take(200)
language = bookInfo.language
published = bookInfo.published
identifiers = bookInfo.identifiers.take(200)
isbn = bookInfo.isbn.take(20)
comments = bookInfo.comments.take(255)
cover = ExposedBlob(bookInfo.cover)
}
@@ -0,0 +1,19 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.Publisher
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object PublisherTable : IntIdTable("publisher") {
var name = varchar("name", 100).index()
}
class PublisherDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<PublisherDAO>(PublisherTable)
var name by PublisherTable.name
}
fun publisherDaoToModel(dao: PublisherDAO) = Publisher(dao.id.value, dao.name)
@@ -0,0 +1,19 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.Series
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object SeriesTable : IntIdTable("series") {
var name = varchar("name", 100).index()
}
class SeriesDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<SeriesDAO>(SeriesTable)
var name by SeriesTable.name
}
fun seriesDaoToModel(dao: SeriesDAO) = Series(dao.id.value, dao.name)
@@ -0,0 +1,19 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.Tag
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object TagsTable : IntIdTable("tags") {
var tag = varchar("tag", 50).index()
}
class TagsDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<TagsDAO>(TagsTable)
var tag by TagsTable.tag
}
fun tagDaoToModel(dao: TagsDAO): Tag = Tag(dao.id.value, dao.tag)
@@ -0,0 +1,18 @@
package net.keinekekse.app.books.db.schema
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object TagsToBookTable : IntIdTable("tag_to_book") {
var tagId = reference("tag_id", TagsTable.id).index()
var bookId = reference("book_id", BooksTable.id).index()
}
class TagsToBookDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<TagsToBookDAO>(TagsToBookTable)
var tag by TagsDAO referencedOn TagsToBookTable.tagId
var book by BookDAO referencedOn TagsToBookTable.bookId
}
@@ -0,0 +1,10 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class Artist(
val id: Int = 0,
val name: String = "",
val givenName: String = "",
)
@@ -0,0 +1,9 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class ArtistRole(
val id: Int = 0,
val role: String = "",
)
@@ -0,0 +1,76 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class ArtistForBook(val artist: Artist, val role: ArtistRole)
@Serializable
data class Book(
val id: Int = 0,
val title: String = "",
val subTitle: String? = null,
val originalTitle: String? = null,
val year: Int = 0,
val originalYear: Int? = null,
val language: Languages = Languages.DE,
val isbn: String = "",
val publisher: Publisher,
val comments: String = "",
val cover: ByteArray = byteArrayOf(0),
val series: Series? = null,
val seriesIndex: Int? = null,
val parentBookId: Int? = null, // ID des Sammelbands
val containedBooks: List<Book> = emptyList(), // Enthaltene Romane
val artists: List<ArtistForBook> = emptyList(),
val tags: List<Tag> = emptyList(),
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Book
if (id != other.id) return false
if (year != other.year) return false
if (originalYear != other.originalYear) return false
if (seriesIndex != other.seriesIndex) return false
if (parentBookId != other.parentBookId) return false
if (title != other.title) return false
if (subTitle != other.subTitle) return false
if (originalTitle != other.originalTitle) return false
if (language != other.language) return false
if (isbn != other.isbn) return false
if (publisher != other.publisher) return false
if (comments != other.comments) return false
if (!cover.contentEquals(other.cover)) return false
if (series != other.series) return false
if (containedBooks != other.containedBooks) return false
if (artists != other.artists) return false
if (tags != other.tags) return false
return true
}
override fun hashCode(): Int {
var result = id
result = 31 * result + year
result = 31 * result + originalYear.hashCode()
result = 31 * result + seriesIndex.hashCode()
result = 31 * result + parentBookId.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + subTitle.hashCode()
result = 31 * result + originalTitle.hashCode()
result = 31 * result + language.hashCode()
result = 31 * result + isbn.hashCode()
result = 31 * result + publisher.hashCode()
result = 31 * result + comments.hashCode()
result = 31 * result + cover.contentHashCode()
result = 31 * result + series.hashCode()
result = 31 * result + containedBooks.hashCode()
result = 31 * result + artists.hashCode()
result = 31 * result + tags.hashCode()
return result
}
}
@@ -0,0 +1,18 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class CalibreBookInfo(
val id: Int = 0,
val title: String,
val authors: String,
val publisher: String,
val tags: String,
val language: Languages,
val published: Int,
val identifiers: String,
val isbn: String,
val comments: String,
val cover: ByteArray,
)
@@ -0,0 +1,7 @@
package net.keinekekse.app.books.model
enum class Languages {
DE,
EN,
FR,
}
@@ -0,0 +1,6 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class Publisher(val id: Int, val name: String)
@@ -0,0 +1,6 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class Series(val id: Int, val name: String)
@@ -0,0 +1,6 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class Tag(val id: Int, val tag: String)
@@ -0,0 +1,98 @@
package net.keinekekse.app.books.scan
import io.ktor.util.logging.*
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import net.keinekekse.app.books.db.CalibreBookInfoRepositoryImpl
import net.keinekekse.app.books.model.CalibreBookInfo
import net.keinekekse.app.books.model.Languages
import net.keinekekse.exec.CommandExecutionException
import net.keinekekse.exec.ExecutionResult
import org.jetbrains.exposed.v1.jdbc.Database
import java.io.File
import kotlin.time.Instant
val LOG = KtorSimpleLogger("net.keinekekse.app.books.scan.fetchBookInfo")
suspend fun getIsbnFromCalibre(isbn: String, db: Database): String {
try {
val runResult = ExecutionResult(
listOf(
"fetch-ebook-metadata",
"--isbn",
isbn,
"--cover",
"/opt/app/download/$isbn.jpg"
)
)
LOG.info("fetch-ebook-metadata for isbn: $isbn")
LOG.info("RC = ${runResult.rc}")
LOG.info("Output = ${runResult.stdout}")
LOG.info("Error = ${runResult.stderr}")
val cali = extractData(runResult.stdout, isbn)
CalibreBookInfoRepositoryImpl(db).addBookInfo(cali)
LOG.info("CalibreBookInfo.language = ${cali.language}")
LOG.info("CalibreBookInfo.published = ${cali.published}")
return runResult.stdout
} catch (e: CommandExecutionException) {
LOG.error("Error running fetch-ebook-metadata for isbn: $isbn", e)
return "nothing found for $isbn"
}
}
fun extractData(input: String, isbn: String): CalibreBookInfo {
val title = extractLine(input, "Title")?.trim() ?: ""
val authors = extractLine(input, "Author.s.")?.trim() ?: ""
val publisher = extractLine(input, "Publisher")?.trim() ?: ""
val tags = extractLine(input, "Tags")?.trim() ?: ""
val languages = extractLine(input, "Languages")?.trim() ?: ""
val published = extractLine(input, "Published")?.trim() ?: ""
val identifiers = extractLine(input, "Identifiers")?.trim() ?: ""
val comments = extractLine(input, "Comments")?.trim() ?: ""
// val authorList = authors.split("&").map { it.trim() }
val lang = Languages.valueOf(languages.uppercase().slice(0..1))
val year = Instant.parse(published).toLocalDateTime(TimeZone.UTC).year
val cover = getCover(isbn)
return CalibreBookInfo(
0,
title,
authors,
publisher,
tags,
lang,
year,
identifiers,
isbn,
comments,
cover
)
}
fun extractLine(input: String, text: String): String? {
val pat = """$text\s*:\s*(.*)"""
val regex = Regex(pat)
return regex.find(input)?.groupValues?.get(1)
// if (matchResult != null) {
// val title = matchResult.groupValues[1]
// LOG.info("Title: $title")
// }
}
fun getCover(isbn: String): ByteArray {
var coverBytes: ByteArray = ByteArray(0)
val file = File("/opt/app/download/$isbn.jpg")
if (file.exists()) {
LOG.info("Cover file exists")
coverBytes = file.readBytes()
} else {
LOG.info("Cover file does not exist")
}
return coverBytes
}
+202
View File
@@ -0,0 +1,202 @@
package net.keinekekse.app.books.ui
import io.ktor.htmx.html.*
import kotlinx.coroutines.runBlocking
import kotlinx.html.*
import net.keinekekse.app.books.db.BookRepository
import net.keinekekse.app.books.model.*
import net.keinekekse.app.books.model.Tag
fun FlowContent.bookEdit(book: Book, booksRepo: BookRepository) {
val publishers: List<Publisher> = runBlocking {
booksRepo.allPublishers()
}
val series: List<Series> = runBlocking {
booksRepo.allSeries()
}
val artists: List<Artist> = runBlocking {
booksRepo.allArtists()
}
val tags: List<Tag> = runBlocking {
booksRepo.allTags()
}
div(classes = "flex flex-col m-8") {
form {
attributes.hx {
post = navBooksEdit.second
target = "#response"
swap = "innerHTML"
}
div(classes = "flex flex-row mb-5 h-9") {
div(classes = "w-30 ") {
+"Titel:"
}
input(classes = "$cssTextInput w-150") {
name = "title"
tabIndex = "1"
value = book.title
}
}
div(classes = "flex flex-row mb-5 h-9") {
div(classes = "w-30 ") {
+"Originaltitel:"
}
input(classes = "$cssTextInput w-150") {
name = "originaltitle"
tabIndex = "2"
value = book.originalTitle ?: ""
}
}
div(classes = "flex flex-row mb-5 h-9") {
div(classes = "w-30 ") {
+"Untertitel:"
}
input(classes = "$cssTextInput w-150") {
name = "subtitle"
tabIndex = "3"
value = book.subTitle ?: ""
}
}
div(classes = "flex flex-row mb-5 h-9") {
div(classes = "w-30") {
+"Sprache"
}
select(classes = "$cssTextInput w-16") {
name = "language"
tabIndex = "4"
for (language in Languages.entries) {
option {
if (language == book.language)
selected = true
value = language.ordinal.toString()
+language.name
}
}
}
div(classes = "ml-9 pr-3 ") {
+"Erscheinungsjahr:"
}
input(classes = "$cssTextInput w-16") {
name = "year"
tabIndex = "5"
value = book.year.toString()
}
div(classes = "ml-8 pr-3") {
+"Jahr der Originalausgabe:"
}
input(classes = "$cssTextInput w-16") {
name = "originalyear"
tabIndex = "6"
value = book.originalYear?.toString() ?: ""
}
}
div(classes = "flex flex-row mb-5 h-9") {
div(classes = "w-30") {
+"ISBN"
}
input(classes = "$cssTextInput w-40") {
name = "isbn"
tabIndex = "7"
value = book.isbn
}
div(classes = "ml-14 pr-3") {
+"Verlag"
}
select(classes = "$cssTextInput w-74") {
name = "publisher"
tabIndex = "8"
option {
value = "0"
+"---"
}
for (pub in publishers) {
option {
if (pub == book.publisher)
selected = true
value = pub.id.toString()
+pub.name
}
}
}
a(classes = cssFormIcon) {
tabIndex = "9"
title = "Verlag hinzufügen"
materialSymbolFormButton("add_box")
}
}
div(classes = "flex flex-row mb-5 h-9") {
div(classes = "w-30") {
+"Serie"
}
select(classes = "$cssTextInput w-98") {
name = "series"
tabIndex = "10"
option {
value = "0"
+"---"
}
for (serie in series) {
option {
if (serie == book.series)
selected = true
value = serie.id.toString()
+serie.name
}
}
}
a(classes = cssFormIcon) {
tabIndex = "11"
title = "Serie hinzufügen"
materialSymbolFormButton("add_box")
}
div(classes = "ml-13 pr-3 ") {
+"Index"
}
input(classes = "$cssTextInput w-16") {
name = "seriesindex"
tabIndex = "12"
value = book.seriesIndex?.toString() ?: ""
}
}
div(classes = "flex flex-row mb-5 h-9") {
// artists tags
// | | | |
// | | | |
}
div(classes = "flex flex-row mb-5 h-9") {
// evtl. switch
// Teil von Sammelband _____v / enthält (-> Link)
// comment
}
button(classes = "$cssButtonPri block mt-8") {
tabIndex = "20"
type = ButtonType.submit
+"Save"
}
}
div {
id = "response"
}
}
}
@@ -0,0 +1,97 @@
package net.keinekekse.app.books.ui
import kotlinx.html.*
import net.keinekekse.app.VERSION_books
import net.keinekekse.app.core.ui.cssFullscreen
import net.keinekekse.app.core.ui.stdHead
fun FlowContent.programName() {
a(classes = "flex") {
href = navBooks.second
title = "v ${VERSION_books.fullVersion}"
img(classes = "h-14 my-2 $bgHeaderIcon") {
src = "/static/books.png"
}
div(classes = "self-center text-right ml-3") {
div {
div(classes = "text-3xl") {
+VERSION_books.displayName
}
div(classes = "text-xs") {
+VERSION_books.version
}
}
}
}
}
fun HTML.booksPage(title: String, breadcrumbs: Map<String, String>, block: DIV.() -> Unit = {}) {
// val booksDb = Dependencies.getBooksDb()
LOG.info(" ### building test page")
// var breadcrumbMap = emptyMap<String, String>()
// if (breadcrumbs.isEmpty()) {
// breadcrumbMap = breadcrumbs.plus(Pair("Start", ""))
// } else {
// breadcrumbMap = mapOf(Pair("Start", "/books")).plus(breadcrumbs)
// }
head {
stdHead(title)
}
body {
// page
div(classes = "$cssFullscreen flex-col") {
// header
div(classes = "flex flex-row justify-between py-2 px-5 $bgHeader $textHeader") {
// header left
div(classes = "flex flex-row") {
programName()
div(classes = "ml-10 flex self-end pr-5 pt-2 border-t-1 items-baseline") {
a(classes = cssHeaderLink) {
href = "/"
materialSymbol("home")
// +"Start"
}
div(classes = "px-3") { +">" }
breadcrumbs.forEach { (name, url) ->
if (url.isNotEmpty()) {
a(classes = cssHeaderLink) {
href = url
+name
}
div(classes = "px-3") { +">" }
} else {
div {
+name
}
}
}
}
}
// header right TODO nötig?
// div(classes = "flex") {
// div(classes = "self-center text-xl") {
// materialSymbol("Settings")
// }
// }
}
div(classes = "flex flex-row flex-auto overflow-hidden") {
block()
}
// footer
div(classes = "p-1 text-sm $bgFooter") {
div(classes = "col") {
+"some footer content"
}
}
}
}
}
+137
View File
@@ -0,0 +1,137 @@
package net.keinekekse.app.books.ui
import io.ktor.util.logging.*
import net.keinekekse.app.books.db.BookRepository
import net.keinekekse.app.books.db.schema.*
import net.keinekekse.app.books.model.Languages
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
val logger = KtorSimpleLogger("net.keinekekse.app.books.ui")
suspend fun dbTest(db: Database, booksRepo: BookRepository) {
// val db = Dependencies.getBooksDb()
// val repo = Dependencies.getBookRepository()
// DB-Test
transaction(db) {
logger.debug("start DB reset - drop")
// create if not exists
SchemaUtils.drop(
PublisherTable,
TagsTable,
TagsToBookTable,
SeriesTable,
ArtistToBookTable,
ArtistRolesTable,
ArtistsTable,
BooksTable,
CalibreBookInfoTable
)
logger.debug("create tables")
SchemaUtils.create(
PublisherTable,
TagsTable,
TagsToBookTable,
SeriesTable,
ArtistRolesTable,
ArtistsTable,
ArtistToBookTable,
BooksTable,
CalibreBookInfoTable
)
logger.debug("Tables created")
val auth = ArtistRoleDAO.new { role = "Autor" }
ArtistRoleDAO.new { role = "Übersetzer" }
val kia1 = ArtistDAO.new {
name = "Aigner"
givenName = "Sebastian"
}
val kia2 = ArtistDAO.new {
name = "Elizarov"
givenName = "Roman"
}
val kia3 = ArtistDAO.new {
name = "Isakova"
givenName = "Svetlana"
}
val kia4 = ArtistDAO.new {
name = "Jemerov"
givenName = "Dmitry"
}
val kp1 = ArtistDAO.new {
name = "Soshin"
givenName = "Alexey"
}
val pub1 = PublisherDAO.new {
name = "Manning"
}
val pub2 = PublisherDAO.new {
name = "Packt Publishing"
}
val kia = BookDAO.new {
title = "Kotlin in Action, 2nd Edition"
originalTitle = ""
subTitle = ""
year = 2024
originalYear = 2024
seriesIndex = 0
language = Languages.EN
isbn = "9781617296823"
publisher = pub1
comments = ""
}
val kp = BookDAO.new {
title = "Kotlin Design Patterns and Best Practices, 3rd Edition"
originalTitle = ""
subTitle = ""
year = 2024
originalYear = 2024
seriesIndex = 0
language = Languages.EN
isbn = "9781617298823"
publisher = pub2
comments = ""
}
ArtistToBookDAO.new {
book = kia
role = auth
artist = kia1
}
ArtistToBookDAO.new {
book = kia
role = auth
artist = kia2
}
ArtistToBookDAO.new {
book = kia
role = auth
artist = kia3
}
ArtistToBookDAO.new {
book = kia
role = auth
artist = kia4
}
ArtistToBookDAO.new {
book = kp
role = auth
artist = kp1
}
}
val books = booksRepo.allBooks()
books.forEach { book ->
logger.debug("### ${book.title}:")
book.artists.forEach { atb ->
logger.debug("### ${atb.role.role}: ${atb.artist.givenName} ${atb.artist.name}")
}
}
}
+119
View File
@@ -0,0 +1,119 @@
package net.keinekekse.app.books.ui
import kotlinx.html.FlowContent
import kotlinx.html.div
import kotlinx.html.span
import kotlinx.html.style
import net.keinekekse.app.books.isbnScanResultPath
import kotlin.io.encoding.Base64
// * colors *
const val bgHeader = "bg-orange-500"
const val textHeader = "text-orange-100"
const val bgHeaderIcon = "bg-orange-100"
const val bgFooter = "bg-orange-50"
const val bgLeftColumn = "bg-slate-200"
const val bgRightColumn = "bg-slate-50"
const val bgThead = "bg-slate-200"
const val borderTable = "border-slate-400"
const val bgStdButton = "bg-taupe-500"
const val bgStdButtonHover = "hover:bg-taupe-700"
const val bgStdButtonFocus = "focus:bg-taupe-700"
const val bgPriButton = "bg-orange-400"
const val bgPriButtonHover = "hover:bg-orange-600"
const val bgPriButtonFocus = "focus:bg-orange-600"
const val bgSecButton = "bg-indigo-400"
const val bgSecButtonHover = "hover:bg-indigo-600"
const val bgSecButtonFocus = "focus:bg-indigo-600"
const val textLink = "text-orange-400"
const val textLinkHover = "hover:text-orange-600"
const val textLinkFocus = "focus:text-orange-600"
const val bgLinkHover = "hover:bg-orange-200"
const val bgLinkFocus = "focus:bg-orange-200"
// * button & link *
const val cssButton = "cursor-pointer rounded-md px-4 py-2"
const val cssButtonStd =
"text-white $bgStdButton $bgStdButtonHover $cssButton $bgStdButtonFocus"
const val cssButtonPri =
"text-white $bgPriButton $bgPriButtonHover $cssButton $bgPriButtonFocus"
const val cssButtonSec =
"text-white $bgSecButton $bgSecButtonHover $cssButton $bgSecButtonFocus"
const val cssHeaderLink =
"font-bold px-1 $textLinkHover $textLinkFocus $bgLinkHover $bgLinkFocus rounded-md cursor-pointer m-0 no-underline"
const val cssLink =
"font-bold $textLink $textLinkHover $textLinkFocus $bgLinkHover $bgLinkFocus rounded-md cursor-pointer m-0 no-underline"
const val cssLinkText =
"px-1 $cssLink"
const val cssLinkIcon =
"px-2 $cssLink "
const val cssFormIcon =
"font-bold text-slate-400 $textLinkHover $textLinkFocus $bgLinkHover $bgLinkFocus rounded-md " +
"cursor-pointer m-0 px-2 pt-1 no-underline "
const val cssTextInput =
"rounded-sm border-1 border-slate-400 px-2 py-1 focus:border-orange-400 focus:border-4" +
" invalid:focus:bg-red-200 invalid:focus:border-red-500 invalid:focus:border-5" +
" invalid:bg-red-200 invalid:border-red-500 invalid:border-3 "
const val _cssTextInput =
"rounded-sm border-b-1 px-2 py-1 focus:border-b-orange-400 focus:border-b-4" +
" invalid:focus:bg-red-200 invalid:focus:border-red-500 invalid:focus:border-5" +
" invalid:bg-red-200 invalid:border-red-500 invalid:border-3 "
// * fixed table header *
const val cssFixedTHead = "fixed-table-header $bgThead "
// m-1 -> margin (--spacing * 1) -> --spacing = .25em = 4px => m-1 = 4px, m-2 = 8px
// Navigation
val navBooks = "Bücher" to "/books"
val navBooksEdit = "Buch bearbeiten" to "/books/edit"
val navBooksNew = "Neue Bücher" to "/books/new"
val navBooksScan = "ISBN Scan" to "/books/new/scan"
val navBooksScanFetch = "" to isbnScanResultPath // has to be shared with :web-Project
val navBooksScanEdit = "Scan bearbeiten" to "/books/new/editscan"
fun FlowContent.materialSymbol(symbol: String, styles: String = "") {
// Den Icon via Tailwind formatieren klappt nicht. Daher muss z. B. font-size oder
// font-weight per style= gesetzt werden.
span(classes = "material-symbols-outlined align-text-bottom") {
style = styles
+symbol
}
}
fun FlowContent.materialSymbolButton(symbol: String, styles: String = "") {
div(classes = "$cssLinkIcon inline-block") {
materialSymbol(symbol, styles)
}
}
fun FlowContent.materialSymbolFormButton(symbol: String, styles: String = "") {
div(classes = " inline-block") {
materialSymbol(symbol, styles)
}
}
/**
* Converts a byte array representing an image into a Base64 encoded Data URL string.
*
* Use in &lt;img src="..."&gt;
*
* @param image A nullable byte array containing the binary data of the image.
* When null or empty, an empty string is returned.
* @return A Data URL string containing the Base64 encoded image data,
* or an empty string if the input is null or empty.
*/
fun embedImageURL(image: ByteArray?): String =
if (image != null && image.isNotEmpty()) {
val base64String =
Base64.encode(image) // kotlin.io.encoding.Base64
val mimeType = "image/jpeg"
"data:$mimeType;base64,$base64String"
} else ""
@@ -0,0 +1,72 @@
package net.keinekekse.app.books.ui
import kotlinx.html.HTML
import kotlinx.html.div
import kotlinx.html.img
import kotlinx.html.span
import net.keinekekse.app.books.db.BookRepository
import net.keinekekse.app.books.model.Book
import net.keinekekse.app.books.model.CalibreBookInfo
import net.keinekekse.app.books.model.Languages
import net.keinekekse.app.books.model.Publisher
fun HTML.importEditPage(bookInfo: CalibreBookInfo?, booksRepo: BookRepository) {
val dataUrl = embedImageURL(bookInfo?.cover)
booksPage(
navBooksScanEdit.first,
mapOf(navBooks, navBooksNew, Pair(navBooksScanEdit.first, ""))
) {
div(classes = "flex flex-col flex-auto") {
div(classes = "flex flex-row h-60 m-8 w-3/4") {
img(classes = "inline-block h-60 pr-6 ") {
src = dataUrl
}
div {
div {
span(classes = "inline-block w-25 ") { +"ISBN: " }
+(bookInfo?.isbn ?: "")
}
div {
span(classes = "inline-block w-25") { +"Titel: " }
+(bookInfo?.title ?: "")
}
div {
span(classes = "inline-block w-25") { +"Autor(en): " }
+(bookInfo?.authors ?: "")
}
div {
span(classes = "inline-block w-25") { +"Verlag: " }
+(bookInfo?.publisher ?: "")
}
div {
span(classes = "inline-block w-25") { +"Jahr: " }
+bookInfo?.published.toString()
}
div {
span(classes = "inline-block w-25") { +"Tags: " }
+(bookInfo?.tags ?: "")
}
div {
span(classes = "inline-block w-25") { +"Identifiers: " }
+(bookInfo?.identifiers ?: "")
}
div {
span(classes = "inline-block w-25") { +"Comment: " }
+(bookInfo?.comments ?: "")
}
}
}
bookEdit(
Book(
title = bookInfo?.title ?: "",
year = bookInfo?.published ?: 0,
isbn = bookInfo?.isbn ?: "",
publisher = Publisher(0, ""),
language = bookInfo?.language ?: Languages.DE
), booksRepo
)
}
}
}
@@ -0,0 +1,110 @@
package net.keinekekse.app.books.ui
import kotlinx.html.*
import net.keinekekse.app.books.model.CalibreBookInfo
fun HTML.importPage(books: List<CalibreBookInfo>) {
// head {
// stdHead("ISBN Scannen", false)
// }
// body {
booksPage(navBooksNew.first, mapOf(navBooks, Pair(navBooksNew.first, ""))) {
div(classes = "basis-64 px-1 py-2 text-center $bgLeftColumn") {
div {
+"some content on the left "
}
div(classes = "my-6") {
a(classes = cssButtonPri) {
href = navBooksScan.second
+"ISBN Scan"
}
}
}
// content right
div(classes = "flex flex-auto $bgRightColumn") {
div(classes = "flex flex-col flex-auto") {
div {
// +"some heading"
}
importTable(books)
}
}
}
}
fun FlowContent.importTable(books: List<CalibreBookInfo>) {
div(classes = "flex-auto overflow-auto px-1 pb-1") {
table {
id = "importtable"
thead(classes = "$cssFixedTHead ") {
tr {
th(classes = "py-3 px-2") { +"ISBN" }
th(classes = "py-3 px-2") { +"Titel" }
th(classes = "py-3 px-2") { +"Autoren" }
th(classes = "py-3 px-2") { +"Verlag" }
th(classes = "py-3 px-2") { +"Jahr" }
th(classes = "py-3 px-2") { +"Sprache" }
th(classes = "py-3 px-2") { +" " }
}
}
tbody {
for ((id, title, authors, publisher, tags, language, published, identifiers, isbn, comment, cover) in books) {
tr {
td(classes = "border-t-1 $borderTable px-2 py-1") {
+isbn
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+title
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+authors
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+publisher
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+published.toString()
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+language.name
}
td(classes = "border-t-1 $borderTable px-2 py-1 text-nowrap") {
a {
href = "${navBooksScanEdit.second}/$id"
materialSymbolButton("Edit")
}
materialSymbolButton("Delete")
}
}
tr {
td {}
td {
colSpan = "5"
details(classes = "mb-2") {
attributes["name"] = "comments"
summary(classes = "text-sm opacity-50") {
+"Tags, Comments"
}
div(classes = "flex flex-row") {
img(classes = "inline-block h-30 pr-6 ") {
src = embedImageURL(cover)
}
div {
+tags
br {}
+identifiers
br {}
+comment
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,94 @@
package net.keinekekse.app.books.ui
import kotlinx.html.*
fun HTML.scanPage() {
// head {
// stdHead("ISBN Scannen", false)
// }
// body {
booksPage(
navBooksScan.first,
mapOf(navBooks, navBooksNew, Pair(navBooksScan.first, ""))
) {
style {
type = "text/css"
+"""
button {
background-color: var(--color-orange-400);
color: white;
padding: 8px 16px;
border: none;
border-radius: 5px;
cursor: pointer;
}
button:hover {
background-color: var(--color-orange-600);
}
h3 {
font-weight: bold;
margin: 10px;
}
""".trimIndent()
}
div(classes = "m-4 overflow-auto flex-auto") {
h3 { +"ISBN / Barcode erfassen" }
// 1. Kamera-Ausgabebereich
div {
id = "camera"
style = "width: 400px;"
}
// 2. Zielbereich für das Buch-Ergebnis (wird per HTMX befüllt)
div {
id = "scan-result"
// p { +"Bitte Barcode vor die Kamera halten..." }
}
}
// 3. JavaScript zur Initialisierung & HTMX-Bridge
script {
unsafe {
raw(
"""
document.addEventListener('DOMContentLoaded', function () {
renderScanner("camera");
});
""".trimIndent()
)
}
}
}
}
fun TagConsumer<Appendable>.isbnResultCallback(bookInfo: String, dataUrl: String) {
div {
pre {
// gefundene Daten anzeigen -> Bearbeitung später
+bookInfo
}
if (dataUrl.isNotEmpty()) {
div {
// img(src = "/books/testimage/$isbn")
img(src = dataUrl) {
style = "height: 200px; margin-bottom: 10px;"
}
}
}
button {
style = "margin-left: 10px; font-size: 16px;"
onClick = "renderScanner('camera')"
+"Nächtes Buch"
}
button {
style = "margin-left: 10px; font-size: 16px;"
type = ButtonType.button
onClick = "location.href='${navBooksNew.second}'"
+"Fertig"
}
}
}
+279
View File
@@ -0,0 +1,279 @@
@file:OptIn(ExperimentalKtorApi::class)
package net.keinekekse.app.books.ui
import io.ktor.htmx.*
import io.ktor.htmx.html.*
import io.ktor.util.logging.*
import io.ktor.utils.io.*
import kotlinx.html.*
import net.keinekekse.app.core.config.LOGOUT_PATH
import org.jetbrains.exposed.v1.jdbc.Database
import kotlin.random.Random
val LOG = KtorSimpleLogger("net.keinekekse.app.books.ui.TestPage")
//fun FlowContent.programName() {
//
// a(classes = "flex") {
// href = "/"
// title = "v ${VERSION_books.fullVersion}"
//
// img(classes = "h-14 my-2 $bgHeaderIcon") {
// src = "/static/books.png"
// }
// div(classes = "self-center text-right ml-3") {
// div {
// div(classes = "text-3xl") {
// +VERSION_books.displayName
// }
// div(classes = "text-xs") {
// +VERSION_books.version
// }
// }
// }
// }
//}
//
//fun HTML.testPage(booksDb: Database) {
//// val booksDb = Dependencies.getBooksDb()
// LOG.info(" ### building test page")
//
// head {
// stdHead("Test Page")
// }
//
// body {
// // page
// div(classes = "$cssFullscreen flex-col") {
// // header
// div(classes = "flex flex-row justify-between py-2 px-5 $bgHeader $textHeader") {
// // header left
// div(classes = "flex flex-row") {
// programName()
// div(classes = "ml-10 flex self-center") {
// +"MENÜ"
// }
// }
// // header right
// div(classes = "flex") {
// div(classes = "self-center text-xl") {
// materialSymbol("Settings")
// }
// }
// }
fun HTML.testPage(booksDb: Database) {
booksPage("Books-DB", mapOf(Pair(navBooks.first, ""))) {
// content left
div(classes = "basis-64 px-1 py-2 text-center $bgLeftColumn") {
div {
+"some content on the left "
}
div {
button(classes = "$cssButtonStd m-2") {
attributes.hx {
// TODO
get = "/books/testdb"
target = "#testdb"
swap = HxSwap.innerHtml
trigger = "click"
}
+"DB test"
}
}
div {
button(classes = "$cssButtonPri m-2 focus:outline-none focus:outline-orange-800") {
+"primary button"
}
}
div {
+"text text "
button(classes = "$cssLinkText ") { +"click me" }
+" test test"
}
div {
button(classes = "$cssButtonSec m-2") { +"secondary button" }
}
div {
+"Database: ${booksDb.vendor} ${booksDb.version}"
}
div {
form(action = LOGOUT_PATH, method = FormMethod.post) {
button(classes = cssLinkText) {
type = ButtonType.submit
+"Logout"
}
}
}
div {
id = "testdb"
}
div(classes = "mt-6") {
// script {
// unsafe {
// raw(
// """
// function toggleField(hideObj,showObj){
// hideObj.disabled=true;
// hideObj.style.display='none';
// showObj.disabled=false;
// showObj.style.display='inline';
// showObj.focus();
// }
// """.trimIndent()
// )
// }
//
// }
// fkt. nur so lala (onBlur!), lässt sich das hinbiegen?
// form {
// attributes.hx {
// post = "/books/testform"
// target = "#testForm"
// swap = HxSwap.innerHtml
// }
// name = "BrowserSurvey"
// select {
// name = "browser"
// onChange = """
// if(this.options[this.selectedIndex].value=='customOption'){
// toggleField(this,this.nextSibling);
// this.selectedIndex='0';
// }
// """.trimIndent()
// label { +"Browser: " }
// option {
// value = "customOption"
// +"[type a custom value]"
// }
// option {
// value = "vChrome"
// +"Chrome"
// }
// option {
// value = "vFirefox"
// +"Firefox"
// }
// option {
// value = "vInternetExplorer"
// +"Internet Explorer"
// }
// option {
// value = "vOpera"
// +"Opera"
// }
// }
// input {
// type = InputType.text
// name = "browser"
// style = "display:none;"
// disabled = true
// onBlur = """
// if(this.value==null || this.value==''){toggleField(this,this.previousSibling);}
// """.trimIndent()
// }
// input(type = InputType.submit) {
// value = "Submit"
// }
// }
// klappt, es fehlt halt die "value" -> alpha search ?!
form {
attributes.hx {
post = "/books/testform"
target = "#testForm"
swap = HxSwap.innerHtml
}
input(
classes = cssTextInput,
type = InputType.text
) {
name = "testInput"
list = "lst"
minLength = "2"
dataList {
id = "lst"
option(content = "alpha")
option(content = "beta")
option(content = "gamma")
}
}
input(type = InputType.submit) {
value = "Submit"
}
}
div {
id = "testForm"
}
}
}
// content right
div(classes = "flex flex-auto $bgRightColumn") {
div(classes = "flex flex-col flex-auto") {
// div {
// +"some heading"
// }
testTable()
}
}
}
}
// // footer
// div(classes = "p-1 text-sm $bgFooter") {
// div(classes = "col") {
// +"some footer content"
// }
// }
// }
//// }
//
// }
//}
fun FlowContent.testTable() {
div(classes = "flex-auto overflow-auto px-1 pb-1") {
table {
id = "testtable"
thead(classes = "$cssFixedTHead ") {
tr {
th(classes = "py-3 px-2") { +"#" }
th(classes = "py-3 px-2") { +"Alias" }
th(classes = "py-3 px-2") { +"Score" }
th(classes = "py-3 px-2") {
a {
href = navBooksNew.second
title = navBooksNew.first
materialSymbolButton(
"auto_stories",
"font-weight: bold; font-size: 2em"
)
// library_add
}
}
}
}
tbody {
for (i in 0..100) {
tr {
td(classes = "border-t-1 $borderTable px-2 py-1") {
+i.toString()
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+Random.nextLong().toString()
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+Random.nextLong().toString()
}
td(classes = "border-t-1 $borderTable px-2 py-1 text-nowrap") {
materialSymbolButton("Edit")
materialSymbolButton("Delete")
}
}
}
}
}
}
}
@@ -0,0 +1,52 @@
package net.keinekekse.app.core.auth
import io.ktor.server.auth.oidc.*
import io.ktor.utils.io.*
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.security.Principal
import kotlin.time.Instant
@OptIn(ExperimentalKtorApi::class)
class AppPrincipal(credential: OidcToken.Id) : Principal {
val userId: String = credential.userInfo.preferredUsername ?: ""
val vorname: String = credential.userInfo.givenName ?: ""
val nachname: String = credential.userInfo.familyName ?: ""
val email: String = credential.userInfo.email ?: ""
val roles: Set<UserRoles> = extractRoles(credential)
val expiresAt: Instant? = credential.claims.expiresAt
private fun extractRoles(credential: OidcToken.Id): Set<UserRoles> {
val payload = credential.claims.payload
// 1. Realm-Rollen extrahieren
val realmRoles = payload["realm_access"]
?.jsonObject?.get("roles")
?.jsonArray?.mapNotNull { it.jsonPrimitive.content }
?: emptyList()
// 2. Client-Rollen extrahieren
val clientRoles = payload["resource_access"]
?.jsonObject?.get("pkce-book-app") // TODO parametrisieren
?.jsonObject?.get("roles")
?.jsonArray?.mapNotNull { it.jsonPrimitive.content }
?: emptyList()
println("AppPrincipal: User ${userId}, Roles extracted: ${realmRoles.size}")
// 3. Zusammenführen und auf UserRoles-Enum mappen
return (realmRoles + clientRoles).mapNotNull { rawRole ->
val formatted = rawRole.uppercase().replace("-", "_")
try {
UserRoles.valueOf(formatted)
} catch (_: IllegalArgumentException) {
null // Rollen ignorieren, die nicht im Enum definiert sind
}
}.toSet()
}
override fun getName(): String {
return "$userId ($vorname $nachname) <$email>"
}
}
@@ -0,0 +1,28 @@
package net.keinekekse.app.core.auth
import io.ktor.server.auth.*
import io.ktor.utils.io.*
/**
* Die enum spiegelt die verfügbaren Rollen aus Keycloak wider.
*
* Für den Vergleich wird die Keycloak-Rolle in Großbuchstaben umgewandelt
* und "-" durch "_" ersetzt.
*
* Alle Änderungen auf der Keycloak-Seite müssen hier nachgefahren werden.
*
* Wegen der Art des Vergleichs (für eine Route wird immer nur eine Rolle geprüft)
* ist zu beachten, dass der User jeweils alle benötigten Rollen bekommt. Implizit
* ist nur Admin, der für alle Routen zugelassen ist.
*
*/
@OptIn(ExperimentalKtorApi::class)
enum class UserRoles : AuthenticationRole {
ADMIN,
BOOKS_ADMIN,
BOOKS_USER,
BOOKS_EDITOR,
BOOKS_RO
}
@@ -0,0 +1,78 @@
package net.keinekekse.app.core.config
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import kotlinx.serialization.json.Json
const val LOGIN_PATH = "/oidc/keycloak/login"
const val LOGOUT_PATH = "/oidc/keycloak/logout"
const val REFRESH_PATH = "/oidc/keycloak/refresh"
const val SESSION_NAME = "KEYCLOAK_SESSION"
const val REDIRECT_COOKIE = "loginredirect"
val httpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false // wg. fehlender Felder
}
)
}
install(HttpTimeout) {
requestTimeoutMillis = 10000
connectTimeoutMillis = 5000
}
}
/**
* Handles unauthorized access attempts by logging the request URI and redirecting the user
* to the login page.
*
* @param call The `ApplicationCall` instance that contains details about the current HTTP call,
* including the request and response.
*/
suspend fun onUnauthorized(call: ApplicationCall) {
call.application.log.info("UNAUTHORIZED: uri = ${call.request.local.uri}")
setRedirectCookie(call)
call.respondRedirect(LOGIN_PATH)
}
fun setRedirectCookie(call: ApplicationCall) {
call.response.cookies.append(
name = REDIRECT_COOKIE,
value = call.request.local.uri,
maxAge = 240,
path = "/",
httpOnly = true
)
}
fun getRedirectUri(call: ApplicationCall): String {
val uri = call.request.cookies[REDIRECT_COOKIE]
if (uri != null) {
call.response.cookies.append(
name = REDIRECT_COOKIE,
value = "",
maxAge = 0,
path = "/",
httpOnly = true
)
}
return uri ?: "/"
}
fun Application.getKeyFromConfiguration(key: String, default: String? = null): String {
val value = environment.config.propertyOrNull(key)?.getString() ?: default
// ohne die Konfig-Params geht nix, hier ist nur noch Abbruch möglich
return value
?: throw IllegalStateException("Configuration key '$key' not found, server start aborted.")
}
+13
View File
@@ -0,0 +1,13 @@
package net.keinekekse.app.core.config
import io.ktor.server.application.*
import io.ktor.server.plugins.di.*
fun Application.configureDependencyInjection() {
log.info("Configuring dependency injection")
val kasDb = configureCoreDatabase()
dependencies {
provide("kasDb") { kasDb }
}
}
@@ -0,0 +1,34 @@
package net.keinekekse.app.core.config
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.*
import org.jetbrains.exposed.v1.core.DatabaseConfig
import org.jetbrains.exposed.v1.jdbc.Database
fun Application.configureCoreDatabase(): Database {
log.info("Configuring database")
val config = HikariConfig().apply {
dataSourceClassName = "org.postgresql.ds.PGSimpleDataSource"
username = getKeyFromConfiguration("postgres.kas_schema")
password = getKeyFromConfiguration("postgres.kas_pwd")
addDataSourceProperty("serverName", getKeyFromConfiguration("postgres.host", "localhost"))
addDataSourceProperty("portNumber", getKeyFromConfiguration("postgres.port", "5432"))
addDataSourceProperty("databaseName", username)
maximumPoolSize = 6
isReadOnly = false
transactionIsolation = "TRANSACTION_SERIALIZABLE"
}
val dataSource = HikariDataSource(config)
return Database.connect(
datasource = dataSource,
databaseConfig = DatabaseConfig {
// set other parameters here
}
)
}
@@ -0,0 +1,7 @@
package net.keinekekse.app.core.config
import io.ktor.server.application.*
fun Application.configureHtmlDsl() {
log.info("Configuring html dsl")
}
@@ -0,0 +1,44 @@
package net.keinekekse.app.core.config
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.plugins.cachingheaders.*
import io.ktor.server.plugins.compression.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.defaultheaders.*
import io.ktor.server.plugins.forwardedheaders.*
import net.keinekekse.app.VERSION
fun Application.configureHttp() {
log.info("Configuring HTML")
log.info(" *** ${VERSION.description} ${VERSION.fullVersion} - ${VERSION.copyright} ***")
install(CachingHeaders) {
options { call, outgoingContent ->
when (outgoingContent.contentType?.withoutParameters()) {
ContentType.Text.CSS -> CachingOptions(CacheControl.MaxAge(maxAgeSeconds = 24 * 60 * 60))
else -> null
}
}
}
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowHeader(HttpHeaders.Authorization)
allowHeader("MyCustomHeader")
allowHost("kas.keinekekse.net")
allowHost("kec.keinekekse.net")
// anyHost() // Don't do this in production if possible. Try to limit it.
}
install(Compression)
install(DefaultHeaders) {
header("X-Engine", "Ktor") // will send this header with each response
}
install(ForwardedHeaders) // WARNING: for security, do not include this if not behind a reverse proxy
install(XForwardedHeaders)
}
@@ -0,0 +1,43 @@
package net.keinekekse.app.core.config
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.html.*
import io.ktor.server.http.content.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.utils.io.*
import net.keinekekse.app.core.ui.adminPage
import net.keinekekse.app.core.ui.landingPage
@OptIn(ExperimentalKtorApi::class)
fun Application.configureRouting(keycloakSession: KeycloakSession) {
log.info("Configuring core routing")
routing {
staticResources("/", "/web")
staticResources("/static", "static")
authenticateWithOptional(keycloakSession) {
get("/") {
call.respondHtml {
// ist ein User angemeldet?
val loggedOn = call.principalOrNull != null
landingPage(loggedOn)
}
}
get("/admin") {
call.respondHtml {
adminPage()
}
}
}
get("/json/kotlinx-serialization") {
call.respond(mapOf("hello" to "world"))
}
}
}
@@ -0,0 +1,196 @@
package net.keinekekse.app.core.config
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.oidc.*
import io.ktor.server.plugins.di.*
import io.ktor.server.response.*
import io.ktor.server.sessions.*
import io.ktor.utils.io.*
import net.keinekekse.app.core.auth.AppPrincipal
import net.keinekekse.app.core.auth.UserRoles
import java.io.File
import java.security.SecureRandom
import java.util.*
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalKtorApi::class)
typealias KeycloakSession = AuthenticationSchemeWithRoles<AppPrincipal, UserRoles, SessionContext<OidcToken.Id>, AuthenticationScheme<AppPrincipal, SessionContext<OidcToken.Id>>>
@OptIn(ExperimentalKtorApi::class)
suspend fun Application.configureSecurity() {
log.info("configure Security")
// install(CSRF) {
// // tests Origin is an expected value
// allowOrigin("http://localhost:8080")
//
// // tests Origin matches Host header
// originMatchesHost()
//
// // custom header checks
// checkHeader("X-CSRF-Token")
// }
fun generateRandomString(): String {
val bytes = ByteArray(64)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
val keycloakUrl = getKeyFromConfiguration("keycloak.url")
val keycloakRealm = getKeyFromConfiguration("keycloak.realm")
val keycloakClientId = getKeyFromConfiguration("keycloak.clientId")
val keycloakIssuer = "$keycloakUrl/realms/$keycloakRealm"
val keycloakBaseUrl = "$keycloakIssuer/protocol/openid-connect"
// werden automatisch gesetzt
// val authorizationEndpoint = "$keycloakBaseUrl/auth"
// val tokenEndpoint = "$keycloakBaseUrl/token"
// val certificatesEndpoint = "$keycloakBaseUrl/certs"
// TODO besser irgendwo speichern, damit es Server-Restarts überlebt
// val secret = "vncosiifikvjbaiorfhosdjbkjcfehjdc" //
val secret = generateRandomString()
// TODO Zufallswert extern speichern
val b: ByteArray = byteArrayOf()
val state = byteArrayOf(
1, 2, 3, 4, 5, 6, 7, 8, 9, 0,
1, 2, 3, 4, 5, 6, 7, 8, 9, 0,
1, 2, 3, 4, 5, 6, 7, 8, 9, 0,
1, 2
)
val oidc = install(Oidc) { // OAuth2 OpenId w/PKCE
initialDiscoveryAttempts = 5
initialDiscoveryRetryDelay = 15.seconds
}
val keycloakIdentity = oidc.identityProvider("keycloak") {
issuer = keycloakIssuer
oauth {
clientId = keycloakClientId
clientSecret = secret
stateEncryptionKey = OidcStateEncryptionKey.of(state)
onAuthenticated { idToken ->
call.application.log.info("keycloak: onAuthenticated ${idToken.value}")
// == accessToken
println("keycloak claims ${idToken.claims}")
println(" expiresAt ${idToken.claims.expiresAt}")
println(" issuer ${idToken.claims.issuer}")
// issuer https://kec.keinekekse.net/realms/app-server-realm
println(" audience ${idToken.claims.audience}")
// audience [pkce-book-app]
println(" header ${idToken.claims.header}")
// header {"alg":"RS256","typ":"JWT","kid":"WEQgQnKpr4yjwbehpXIsz0HlW3u5pIA8ie3wv1qi-2I"}
println(" jwtId ${idToken.claims.jwtId}")
// jwtId 0bc5a09a-e1b1-d249-5990-aaf2970dd04f
println(" subject ${idToken.claims.subject}")
// subject b5b40fa3-468f-443c-8eb0-e220bdd353bc
println(" type ${idToken.claims.type}")
// type JWT
println(" payload ${idToken.claims.payload}")
// JWT as JSON (preferred_username -> UserID)
println("keycloak userInfo ${idToken.userInfo}")
println(" subject ${idToken.userInfo.subject}")
// subject b5b40fa3-468f-443c-8eb0-e220bdd353bc
println(" name ${idToken.userInfo.name}")
println(" familyName ${idToken.userInfo.familyName}")
println(" preferredUsername ${idToken.userInfo.preferredUsername}")
// preferredUsername hb42
// URI von der aus der Login ausgelöst wurde (default: "/")
// wird ggf. im authenticateWith-Block via onUnauthenticated(call) gesetzt
val redirect = getRedirectUri(call)
call.application.log.info("loginredirect cookie: $redirect")
// zurück zur Seite, die den Login angefordert hat (oder "/")
call.respondRedirect(redirect)
}
onAuthenticationFailed { cause ->
val reason = (cause as? AuthenticationFailedCause.Error)?.message
call.application.log.error("keycloak: Login failed: $reason")
call.respondRedirect(LOGIN_PATH)
}
logout {
call.application.log.info("keycloak: Logout")
call.respondRedirect("/")
}
sessions {
name = SESSION_NAME
// hier werden die keycloak tokens gespeichert
storage = directorySessionStorage(
File("/opt/app/.sessions") // liegt auf dem Volume
)
// hier ist nur cookie möglich (speichert sessionId)
cookie {
cookie.path = "/"
cookie.maxAgeInSeconds = 600 // length of session
}
// Macht Ärger, wenn die Session serverseitig abgelaufen ist und der
// Client über den Ablauf hinaus idle war. Der nächste Seitenaufruf
// triggert dann einen Refresh, dessen Antwort "400 - invalid_grant" Ktor
// leider mit einer Exception quittiert.
// (s. a. Exception-Handler in StatusPages.kt).
// Ohne läuft die Session einfach ohne Refresh ab, was nicht Sinn
// der Sache ist. Also nachsehen, ob abgelaufen und dann Login triggern.
val custom = OidcTokenRefreshStrategy.Custom { provider, token, now ->
val expiresAt = token.claims.expiresAt
call.application.log.info("#### Refreshing token $expiresAt - $now")
if (expiresAt != null && expiresAt <= now) {
// Token ist abgelaufen
// -> aktuelle Route merken, zum Login und zurück zur Route
call.application.log.info("#### redirect to login")
onUnauthorized(call)
call.application.log.info("#### exit refresh (!?)")
return@Custom null
}
val stale = expiresAt != null && expiresAt <= now + 1.minutes
val refreshToken = token.refreshToken
if (stale && refreshToken != null) {
call.application.log.info("#### Refreshing token with refresh token")
provider.refreshToken(refreshToken).idToken
} else {
token
}
}
tokenRefreshStrategy = custom
}
}
}
// log.info("keycloak issuer: ${keycloakIdentity.issuer}")
// log.info("keycloak name: ${keycloakIdentity.name}")
// log.info("keycloak session name: ${keycloakIdentity.session.name}")
/* Keycloak-Session
*
* Die Rollen des jeweiligen Users werden zwar von Keycloak geliefert
* (Keycloak: Client Scopes->"Roles"->Mappers->"realm roles"/ "client roles"->"Add to ID token")
* allerdings werden sie nicht automatisch in die Session übernommen. Deshalb müssen
* zunächst die Rollen aus dem ID-Token extrahiert werden. Das wird in AppPrincipal erledigt.
* Das Objekt übernimmt zudem alle sonstigen für die Anwendung relevanten Userdaten.
* Mit .mapPrincipal() wird es dann als weiterer Principal in der Session übernommen und mit
* .withRoles() werden die extrahierten Rollen in die Session übernommen.
*
* Fürs Handling ist dabei wichtig, wie auf die AppPrincipal-Instanz im Routing-Kontext
* zugegriffen werden kann:
* val appPrincipal = call.principal // ohne () aufrufen, wird bei .mapPrincipal() als
* // Attribut angelegt!
* val oicdPrincipal = call.principal<OicdToken.Id>() // der automatisch angelegte principal
*/
val keycloakSession = keycloakIdentity.session
.mapPrincipal { idToken -> AppPrincipal(idToken) }
.withRoles { principal -> principal.roles }
dependencies {
provide<OidcProvider> { keycloakIdentity }
provide<KeycloakSession> { keycloakSession }
}
}
@@ -0,0 +1,18 @@
package net.keinekekse.app.core.config
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
fun Application.configureSerialization() {
log.info("Configuring serialization")
install(ContentNegotiation) {
json(
json = kotlinx.serialization.json.Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
}
)
}
}
@@ -0,0 +1,96 @@
package net.keinekekse.app.core.config
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.html.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import kotlinx.html.a
import kotlinx.html.div
import kotlinx.html.h1
import kotlinx.html.span
import net.keinekekse.app.core.ui.*
fun Application.configureStatusPages() {
log.info("Configuring status pages")
install(StatusPages) {
status(HttpStatusCode.Forbidden) {
// TODO Hinweis an den User?
call.application.log.warn("403 Forbidden - redirect to /")
call.respondRedirect("/")
}
status(HttpStatusCode.Unauthorized) {
call.application.log.warn("401 Unauthorized - redirect to /")
// für Redirect nach Login (Cookie läuft nach 2 Minuten ab!)
setRedirectCookie(call)
call.respondHtml {
mainApplicationPage {
div(classes = "$cssFlexColumnCenter w-full pt-10") {
h1(classes = "m-8 px-8 text-xl font-bold $textMainBody") {
+"Fehlende Berechtigung für die Seite "
span(classes = cssCode) {
+call.request.path()
}
}
div(classes = "m-8 inline-flex flex-row") {
div(classes = "px-8") {
a(classes = cssMainLink) {
href = "/"
+"Zurück zur Startseite"
}
}
div(classes = "ml-4 px-8") {
a(classes = cssMainLink) {
href = LOGIN_PATH
+"Anmelden"
}
}
}
}
}
}
}
exception<Throwable> { call, cause ->
call.application.log.error("+++ Exception ", cause)
// Session-Cookie verhindert u. U. die Weiterleitung zur Startseite
// also hier löschen (ein Cookie mit httponly=true kann im Browser
// nicht gelöscht werden).
call.response.cookies.append(
name = SESSION_NAME,
value = "",
maxAge = 0,
path = "/",
domain = null,
secure = true,
httpOnly = true
)
call.respondHtml {
mainApplicationPage {
div(classes = "inline-flex flex-col $cssMainWidth") {
h1(classes = "m-8 text-xl font-bold $textMainBody") {
+"Ein Fehler ist aufgetreten"
}
div(classes = "p-2 font-bold $textMainBody") {
+(cause.message ?: "")
}
div(classes = "flex-auto overflow-auto mt-2 p-2 font-mono $textMainBody") {
+cause.stackTraceToString()
}
div(classes = "m-8") {
a(classes = cssMainLink) {
href = "/"
+"Zurück zur Startseite"
}
}
}
}
}
}
}
}
@@ -0,0 +1,25 @@
package net.keinekekse.app.core.ui
import kotlinx.html.*
import net.keinekekse.app.books.ui.materialSymbol
fun HTML.adminPage() {
val title: String = "Configuration"
head {
stdHead(title)
}
body {
div(classes = "m-4") {
a(classes = "px-2 font-bold text-sky-500 hover:text-sky-700 hover:bg-sky-100 cursor-pointer") {
href = "/"
materialSymbol("Home")
+"Home"
}
}
div(classes = "m-4") {
+"Admin Page"
}
}
}
@@ -0,0 +1,74 @@
package net.keinekekse.app.core.ui
import io.ktor.util.logging.*
import io.ktor.utils.io.*
import kotlinx.html.*
import net.keinekekse.app.VERSION_books
import net.keinekekse.app.books.ui.materialSymbol
import net.keinekekse.app.books.ui.navBooks
import net.keinekekse.app.core.config.LOGIN_PATH
import net.keinekekse.app.core.config.LOGOUT_PATH
val LOG = KtorSimpleLogger("net.keinekekse.app.core.ui.LandingPage")
@OptIn(ExperimentalKtorApi::class)
fun HTML.landingPage(loggedOn: Boolean) {
// val String = "Ktor Application Server"
LOG.info("LandingPage loggedOn $loggedOn")
mainApplicationPage {
div(classes = "$cssFlexColumnCenter w-full") {
div(classes = "p-8") {
a(classes = "inline-flex $cssMainLink") {
href = navBooks.second
img(classes = "h-14 my-2") {
src = "/static/books.png"
}
div(classes = "self-center text-right ml-3") {
div {
div {
+VERSION_books.description
}
div(classes = "text-xs") {
+VERSION_books.version
}
}
}
}
// a(classes = "p-2 text-xl font-bold text-sky-500 hover:text-sky-700 hover:bg-sky-100 cursor-pointer") {
// href = "/books"
// +VERSION_books.description
// }
}
div(classes = "m-8") {
a(classes = cssMainLink) {
href = "/admin"
materialSymbol("Settings")
+" Configuration"
}
}
div(classes = "m-8") {
// a(classes = "p-2 font-bold text-sky-500 hover:text-sky-700 hover:bg-cyan-100 cursor-pointer") {
// href = "/oidc/keycloak/login"
// +"Login"
// }
var url = LOGIN_PATH
var method = FormMethod.get
var text = "Login"
if (loggedOn) {
url = LOGOUT_PATH // /logout ?
method = FormMethod.post
text = "Logout"
}
form(action = url, method = method) {
button(classes = cssMainButton) { // "ml-4 $cssMainLink disabled:text-slate-400 disabled:cursor-not-allowed") {
type = ButtonType.submit
+text
}
}
}
}
}
}
@@ -0,0 +1,59 @@
package net.keinekekse.app.core.ui
import kotlinx.html.*
import net.keinekekse.app.VERSION
import net.keinekekse.app.books.ui.cssButton
const val bgMainHeader = "bg-sky-300"
const val textMainHeader = "text-sky-900"
const val bgMainBody = "bg-cyan-50"
const val textMainBody = "text-sky-500"
const val textMainBodyHover = "hover:text-sky-700"
const val bgMainBodyHover = "hover:bg-sky-200"
const val cssMainWidth = "max-w-270"
const val cssFlexColumnCenter = "inline-flex flex-col items-center"
const val cssFlexRowCenter = "inline-flex flex-row justify-center"
const val cssFullscreen = "flex w-full h-screen overflow-hidden"
const val cssCode = "px-1 font-mono bg-slate-100 rounded-md border-slate-300 border-1"
const val cssMainLink =
"py-2 px-4 text-xl cursor-pointer font-bold $textMainBody $textMainBodyHover $bgMainBodyHover"
const val cssMainButton =
"text-xl font-bold $cssButton $textMainBody bg-sky-200 hover:bg-sky-400 hover:text-sky-50"
fun HTML.mainApplicationPage(block: DIV.() -> Unit = {}) {
head {
stdHead(VERSION.displayName)
}
body {
// Box
div(classes = "$cssFullscreen justify-center") {
// Head
div(classes = "$cssFlexColumnCenter m-10 w-8/10 h-9/10 overflow-hidden $cssMainWidth shadow-xl") {
h1(classes = "$cssFlexRowCenter w-full rounded-t-lg text-4xl font-bold py-3 px-6 $textMainHeader $bgMainHeader") {
a {
href = "https://ktor.io"
img(classes = "h-12 mr-4 ") {
src = "/static/ktor_icon.png"
}
}
div {
title = VERSION.fullVersion
+VERSION.description
}
}
// body
div(classes = "inline-flex $bgMainBody py-10 h-full overflow-hidden w-full rounded-b-lg") {
block()
}
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package net.keinekekse.app.core.ui
import kotlinx.html.*
fun HEAD.stdHead(title: String = "", importStyles: Boolean = true) {
title(title)
meta(name = "viewport", content = "width=device-width, initial-scale=1")
script(src = "/web.js") {}
script {
unsafe {
raw(
"""
function toggleField(hideObj,showObj){
hideObj.disabled=true;
hideObj.style.display='none';
showObj.disabled=false;
showObj.style.display='inline';
showObj.focus();
}
""".trimIndent()
)
}
}
// alle weiteren Stylesheets werden in books.css importiert
if (importStyles)
link(rel = "stylesheet", href = "/static/styles/books.css")
}
@@ -0,0 +1,72 @@
package net.keinekekse.app.app.zz
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import java.sql.Connection
import java.sql.Statement
@Serializable
data class City(val name: String, val population: Int)
class CityService(private val connection: Connection) {
companion object {
private const val CREATE_TABLE_CITIES =
"CREATE TABLE CITIES (ID SERIAL PRIMARY KEY, NAME VARCHAR(255), POPULATION INT);"
private const val SELECT_CITY_BY_ID = "SELECT name, population FROM cities WHERE id = ?"
private const val INSERT_CITY = "INSERT INTO cities (name, population) VALUES (?, ?)"
private const val UPDATE_CITY = "UPDATE cities SET name = ?, population = ? WHERE id = ?"
private const val DELETE_CITY = "DELETE FROM cities WHERE id = ?"
}
init {
val statement = connection.createStatement()
statement.executeUpdate(CREATE_TABLE_CITIES)
}
// Create new city
suspend fun create(city: City): Int = withContext(Dispatchers.IO) {
val statement = connection.prepareStatement(INSERT_CITY, Statement.RETURN_GENERATED_KEYS)
statement.setString(1, city.name)
statement.setInt(2, city.population)
statement.executeUpdate()
val generatedKeys = statement.generatedKeys
if (generatedKeys.next()) {
return@withContext generatedKeys.getInt(1)
} else {
throw Exception("Unable to retrieve the id of the newly inserted city")
}
}
// Read a city
suspend fun read(id: Int): City = withContext(Dispatchers.IO) {
val statement = connection.prepareStatement(SELECT_CITY_BY_ID)
statement.setInt(1, id)
val resultSet = statement.executeQuery()
if (resultSet.next()) {
val name = resultSet.getString("name")
val population = resultSet.getInt("population")
return@withContext City(name, population)
} else {
throw Exception("Record not found")
}
}
// Update a city
suspend fun update(id: Int, city: City) = withContext(Dispatchers.IO) {
val statement = connection.prepareStatement(UPDATE_CITY)
statement.setString(1, city.name)
statement.setInt(2, city.population)
statement.setInt(3, id)
statement.executeUpdate()
}
// Delete a city
suspend fun delete(id: Int) = withContext(Dispatchers.IO) {
val statement = connection.prepareStatement(DELETE_CITY)
statement.setInt(1, id)
statement.executeUpdate()
}
}
+58
View File
@@ -0,0 +1,58 @@
package net.keinekekse.app.core.zz
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import net.keinekekse.app.app.zz.ExposedUser
import net.keinekekse.app.app.zz.ExposedUserService
import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase
// TODO kann weg sobald sich die Beispiele erledigt habne
suspend fun Application.configureExposed() {
val database = R2dbcDatabase.connect(
url = "r2dbc:h2:file:///./h2",
user = "root",
password = "",
)
val userService = ExposedUserService(database).also {
it.createSchema()
}
routing {
// Create user
post("/users") {
val user = call.receive<ExposedUser>()
val id = userService.create(user)
call.respond(HttpStatusCode.Created, id)
}
// Read user
get("/users/{id}") {
val id = call.parameters["id"]?.toUInt() ?: throw IllegalArgumentException("Invalid ID")
val user = userService.read(id)
if (user != null) {
call.respond(HttpStatusCode.OK, user)
} else {
call.respond(HttpStatusCode.NotFound)
}
}
// Update user
put("/users/{id}") {
val id = call.parameters["id"]?.toUInt() ?: throw IllegalArgumentException("Invalid ID")
val user = call.receive<ExposedUser>()
userService.update(id, user)
call.respond(HttpStatusCode.NoContent)
}
// Delete user
delete("/users/{id}") {
val id = call.parameters["id"]?.toUInt() ?: throw IllegalArgumentException("Invalid ID")
userService.delete(id)
call.respond(HttpStatusCode.NoContent)
}
}
}
@@ -0,0 +1,5 @@
package net.keinekekse.app.app.zz
fun interface GreetingService {
fun sayHello(): String
}
@@ -0,0 +1,95 @@
package net.keinekekse.app.app.zz
import io.ktor.htmx.html.*
import io.ktor.utils.io.*
import kotlinx.html.*
import java.nio.file.Paths
import kotlin.io.path.exists
import kotlin.io.path.useLines
import kotlin.random.Random
fun HTML.leaderboardPage(random: Random) {
head {
title("HTMX Example")
script(src = "/web.js") {}
link(rel = "stylesheet", href = "/leaderboard.css")
}
body {
h1 {
+"Leaderboard"
}
table {
id = "leaderboard"
thead {
tr {
th { +"Alias" }
th { +"Score" }
}
}
tbody {
randomRows(random)
}
}
h2 {
id = "total-count"
+"Total: 10"
}
}
}
fun TBODY.randomRows(random: Random) {
for (contestant in generateSequence { random.nextContestant() }.take(10)) {
tr {
td { +contestant.alias }
td { +contestant.score.toString() }
}
}
loadMoreRows()
}
@OptIn(ExperimentalKtorApi::class)
fun TBODY.loadMoreRows() {
tr {
id = "replaceMe"
td {
colSpan = "3"
style = "text-align: center;"
button {
attributes.hx {
get = "/more-rows"
target = "#replaceMe"
swap = "outerHTML"
trigger = "click"
select = "tr"
}
+"Load More..."
}
}
}
}
private val dictionary: List<String> by lazy {
listOf("/usr/share/dict/words", "/usr/dict/words").map(Paths::get).firstOrNull {
it.exists()
}?.let { dictionaryFile ->
dictionaryFile.useLines(Charsets.UTF_8) { lines ->
lines.filter { it.length >= 4 }.toList()
}
} ?: listOf("red", "blue", "green", "yellow", "pink", "violet", "black")
}
fun Random.nextContestant(): Contestant =
Contestant(
nextAlias(),
nextInt(1_000_000)
)
fun Random.nextAlias(): String =
dictionary[nextInt(dictionary.size - 1)]
data class Contestant(
val alias: String,
val score: Int,
)
@@ -0,0 +1,96 @@
package net.keinekekse.app.core.zz
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import net.keinekekse.app.app.zz.City
import net.keinekekse.app.app.zz.CityService
import java.sql.Connection
import java.sql.DriverManager
// TODO kann weg sobald sich die Beispiele erledigt haben
fun Application.configurePostgres() {
val dbConnection: Connection = connectToPostgres(embedded = true)
val cityService = CityService(dbConnection)
// dependency injection
// dependencies {
// provide("testDb") { dbConnection }
// }
routing {
// Create city
post("/cities") {
val city = call.receive<City>()
val id = cityService.create(city)
call.respond(HttpStatusCode.Created, id)
}
// Read city
get("/cities/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
try {
val city = cityService.read(id)
call.respond(HttpStatusCode.OK, city)
} catch (e: Exception) {
call.respond(HttpStatusCode.NotFound)
}
}
// Update city
put("/cities/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
val user = call.receive<City>()
cityService.update(id, user)
call.respond(HttpStatusCode.OK)
}
// Delete city
delete("/cities/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
cityService.delete(id)
call.respond(HttpStatusCode.OK)
}
}
}
/**
* Makes a connection to a Postgres database.
*
* In order to connect to your running Postgres process,
* please specify the following parameters in your configuration file:
* - postgres.url -- Url of your running database process.
* - postgres.user -- Username for database connection
* - postgres.password -- Password for database connection
*
* If you don't have a database process running yet, you may need to [download]((https://www.postgresql.org/download/))
* and install Postgres and follow the instructions [here](https://postgresapp.com/).
* Then, you would be able to edit your url, which is usually "jdbc:postgresql://host:port/database", as well as
* user and password values.
*
*
* @param embedded -- if [true] defaults to an embedded database for tests that runs locally in the same process.
* In this case you don't have to provide any parameters in configuration file, and you don't have to run a process.
*
* @return [Connection] that represent connection to the database. Please, don't forget to close this connection when
* your application shuts down by calling [Connection.close]
* */
fun Application.connectToPostgres(embedded: Boolean): Connection {
Class.forName("org.postgresql.Driver")
if (embedded) {
log.info("Using embedded H2 database for testing; replace this flag to use postgres")
return DriverManager.getConnection("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", "root", "")
} else {
val url = environment.config.property("postgres.url").getString()
log.info("Connecting to postgres database at $url")
val user = environment.config.property("postgres.user").getString()
val password = environment.config.property("postgres.password").getString()
return DriverManager.getConnection(url, user, password)
}
}
@@ -0,0 +1,3 @@
package net.keinekekse.app.app.zz
class UserSession(accessToken: String)
@@ -0,0 +1,56 @@
package net.keinekekse.app.app.zz
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.singleOrNull
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.v1.core.dao.id.UIntIdTable
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.r2dbc.*
import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction
@Serializable
data class ExposedUser(val name: String, val age: Int)
class ExposedUserService(val database: R2dbcDatabase) {
object Users : UIntIdTable() {
val name = varchar("name", length = 50)
val age = integer("age")
}
suspend fun createSchema() {
suspendTransaction(database) {
SchemaUtils.create(Users)
}
}
suspend fun create(user: ExposedUser): UInt = suspendTransaction(database) {
val newRecord = Users.insert {
it[name] = user.name
it[age] = user.age
}
newRecord[Users.id].value
}
suspend fun read(id: UInt): ExposedUser? {
return suspendTransaction(database) {
Users.selectAll()
.where { Users.id eq id }
.map { ExposedUser(it[Users.name], it[Users.age]) }
.singleOrNull()
}
}
suspend fun update(id: UInt, user: ExposedUser) {
suspendTransaction(database) {
Users.update({ Users.id eq id }) {
it[name] = user.name
it[age] = user.age
}
}
}
suspend fun delete(id: UInt) {
suspendTransaction(database) { Users.deleteWhere { Users.id.eq(id) } }
}
}
+5
View File
@@ -0,0 +1,5 @@
package net.keinekekse.app
fun main(args: Array<String>) {
io.ktor.server.netty.EngineMain.main(args)
}
@@ -0,0 +1,33 @@
ktor:
deployment:
port: 7770
application:
modules:
- net.keinekekse.app.core.config.HttpKt.configureHttp
- net.keinekekse.app.core.config.SerializationKt.configureSerialization
- net.keinekekse.app.core.config.StatusPagesKt.configureStatusPages
- net.keinekekse.app.core.config.HtmlDslKt.configureHtmlDsl
- net.keinekekse.app.core.config.SecurityKt.configureSecurity
- net.keinekekse.app.core.config.DatabaseKt.configureCoreDatabase
- net.keinekekse.app.core.config.DIKt.configureDependencyInjection
- net.keinekekse.app.core.config.RoutingKt.configureRouting
- net.keinekekse.app.books.config.DatabaseKt.configureBooksDatabase
- net.keinekekse.app.books.config.DIKt.configureBookDependencyInjection
- net.keinekekse.app.books.config.RoutingKt.configureRouting
postgres:
# DB-Params werden vom Container als env vars gesetzt
host: ${DB_HOST}
port: ${DB_PORT}
books_schema: ${BOOKS_SCHEMA}
books_pwd: ${BOOKS_DB_PASSWORD}
kas_schema: ${KAS_SCHEMA}
kas_pwd: ${KAS_DB_PASSWORD}
keycloak:
url: "https://kec.keinekekse.net"
realm: "app-server-realm"
clientId: "pkce-book-app"
kas:
serverUrl: "https://kas.keinekekse.net"
+18
View File
@@ -0,0 +1,18 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
</root>
<!-- div Schwätzer raushalten -->
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<logger name="org.apache.hc" level="INFO"/>
<logger name="net.keinekekse.app" level="DEBUG"/>
<logger name="io.ktor.server.auth.oidc" level="TRACE"/>
</configuration>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,7 @@
<html>
<head>
</head>
<body>
<h1>Hello Ktor!</h1>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,21 @@
function onScanSuccess (decodedText, decodedResult) {
console.log(`Gescannter Code: ` + decodedText)
// Scanner stoppen, sobald ein Code erkannt wurde
html5QrcodeScanner.clear()
// HTMX AJAX-Aufruf auslösen, um Buchdaten vom Backend zu laden:
htmx.ajax('GET', '/books/isbnlookup/' + encodeURIComponent(decodedText), {
target: '#book-result',
swap : 'innerHTML'
})
}
function renderScanner () {
const html5QrcodeScanner = new Html5QrcodeScanner(
'reader',
{fps: 10, qrbox: {width: 250, height: 150}},
/* verbose= */ false
)
html5QrcodeScanner.render(onScanSuccess)
}
@@ -0,0 +1,31 @@
/* Google Material Symbols (die Datei landet beim Build im web root) */
@import url("/static/styles/google.css");
/* Bootstrap Icon - Alternative zu Google, Datei ist etwa halb so groß */
/* @import url("/bootstrap-icons.min.css"); */
/* Tailwind - .css wird beim Build generiert */
@import url("/static/styles/tailwind.css");
/*
Fixed Table Header -> THEAD
zusätzlich passende background-color setzten
*/
thead.fixed-table-header {
position: sticky;
inset-block-start: 0;
z-index: 1;
}
/*
Den lästigen blauen Rand abschalten, muss bei interaktiven Elementen
gezielt gesetzt werden (i. A. analog hover).
*/
@layer utilities {
*:focus,
*:focus-visible {
outline: none;
border: 0;
}
}
@@ -0,0 +1,35 @@
/*
Material Symbols
----------------
Ansehen unter: https://fonts.google.com/icons
Doku: https://developers.google.com/fonts/docs/material_symbols?hl=de
> git clone https://github.com/google/material-design-icons
> cp material-design-icons/variablefont/*.woff2 .
> rm -rf material-design-icons
MaterialSymbolsOutline*.woff2 nach resources/material-symbols.woff kopieren
<span class="material-symbols-outlined">symbol</span>
bzw.
span(classes = "material-symbols-outlined") { +"symbol" }
*/
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
src: url(/static/fonts/material-symbols.woff2) format('woff2');
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
}
+15
View File
@@ -0,0 +1,15 @@
package net.keinekekse.app
class ServerTest {
// @Test
// klappt nicht, da die Container-Umgebung fehlt (incl. DB)
// -> evtl. später mal checken
// fun `test root endpoint`() = testApplication {
// // loads default configuration
// configure()
// // verify server root returns 200
// assertEquals(HttpStatusCode.OK, client.get("/").status)
// }
}
+42
View File
@@ -0,0 +1,42 @@
pluginManagement {
repositories {
maven("https://nexus.keinekekse.net/repository/maven-public/")
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
maven("https://nexus.keinekekse.net/repository/maven-public/")
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
}
versionCatalogs {
create("masterLibs") {
from("net.keinekekse:version-catalog:141")
}
create("ktorLibs").from("io.ktor:ktor-version-catalog:3.6.0")
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "app"
include(":server")
include(":web")
include(":shared")
+16
View File
@@ -0,0 +1,16 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
sourceSets {
jvm()
js {
browser()
binaries.executable()
}
commonMain.dependencies {
// implementation(project(":server"))
}
}
}
@@ -0,0 +1,3 @@
package net.keinekekse.app.books
const val isbnScanResultPath = "/books/new/scan/fetch"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
cd $(dirname $0)
npx @tailwindcss/cli -i ./input.css -o ../server/build/resources/main/web/tailwind.css
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss" source("../server/src");
+1061
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
{
"dependencies": {
"@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.3.3"
}
}
+27
View File
@@ -0,0 +1,27 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
js {
browser()
binaries.executable()
}
sourceSets {
jsMain.dependencies {
implementation(npm("htmx.org", libs.versions.htmx.get()))
implementation(npm("html5-qrcode", libs.versions.html5Qrcode.get()))
// implementation(npm("@popperjs/core", "2.11.8"))
// implementation(npm("bootstrap", "5.3.8"))
// implementation(npm("tailwindcss", "*"))
}
}
sourceSets {
commonMain.dependencies {
implementation(project(":shared"))
}
}
}
+30
View File
@@ -0,0 +1,30 @@
@file:JsModule("html5-qrcode")
@file:JsNonModule
package net.keinekekse.app
/* AI sagt:
In Kotlin/JS werden Named Exports eines npm-Pakets deklariert, indem die
Annotation auf Dateiebene (@file:JsModule) gesetzt wird. Dadurch weiß der
Compiler, dass jede Top-Level-Klasse in dieser Datei ein benannter Export
des Moduls ist.
*/
external class Html5QrcodeScanner(
elementId: String,
config: dynamic,
verbose: Boolean = definedExternally,
) {
fun render(
successCallback: (decodedText: String, decodedResult: dynamic) -> Unit,
errorCallback: ((errorMessage: String) -> Unit)? = definedExternally,
)
fun clear(): dynamic
}
external class Html5Qrcode(
elementId: String,
config: dynamic = definedExternally,
) {
// Falls später benötigt
}
+5
View File
@@ -0,0 +1,5 @@
package net.keinekekse.app
@JsModule("htmx.org")
@JsNonModule
external object htmx
+71
View File
@@ -0,0 +1,71 @@
package net.keinekekse.app
import kotlinx.browser.document
import kotlinx.browser.window
import net.keinekekse.app.books.isbnScanResultPath
fun main() {
window.asDynamic().renderScanner = ::renderScanner
// htmx reference is needed for import
console.log("htmx loaded ", htmx)
// event handling for htmx
var rowCount = 1
document.addEventListener("htmx:beforeSwap", {
document.getElementById("total-count")?.innerHTML = "Total: ${(++rowCount) * 10}"
})
// Scroll to the bottom of the page after adding content
document.addEventListener("htmx:afterSwap", {
val scrollHeight = document.body?.scrollHeight ?: 0
window.scrollTo(0.0, scrollHeight.toDouble())
})
}
/*
Die fn wird in den Namensraum <project>.<package> veröffentlicht
-> web.net.keinekekse.app
Wenn die fn ohne den Rattenschwanz aufgerufen werden soll, muss sie
in main() so exportiert werden:
js("window.renderScanner = renderScanner")
oder
window.asDynamic().renderScanner = ::renderScanner
Des Weiteren benötigt sie ein DOM-Element (id="reader"), daher ist es sinnvoll,
sie erst bei geladenem DOM auszuführen:
document.addEventListener('DOMContentLoaded', function () {
renderScanner();
})
Beim Import von htmx wird der gesamte Import an ein Objekt namens "default"
gehängt. Da sich auch Ktor auf diesen Import verlässt, ist es hier besser,
zu kapitulieren und das Objekt als htmx.asDynamic().default.<whatever> zu
verwenden.
*/
@OptIn(ExperimentalJsExport::class)
@JsExport
fun renderScanner(scanTarget: String = "reader") {
// Ausgabebereich fürs Ergebnis zurücksetzen
document.getElementById("scan-result")?.innerHTML =
"<p>Bitte Barcode vor die Kamera halten...</p>"
// Scanner öffnen
val config = js("{ fps: 10, qrbox: { width: 300, height: 180 } }")
val scanner = Html5QrcodeScanner(scanTarget, config, /*debug*/ false)
// Ergebnis verarbeiten
scanner.render({ decodedText, _ ->
scanner.clear()
document.getElementById("scan-result")?.innerHTML =
"<p>Hole Daten für ISBN: $decodedText</p>"
console.log("Scanned: $decodedText")
// console.log("htmx: ", htmx)
htmx.asDynamic().default.ajax(
"GET",
"${isbnScanResultPath}/" + js("encodeURIComponent(decodedText)"),
js("{ target: '#scan-result', swap: 'innerHTML' }")
)
}, { errorMessage ->
console.log("Error: $errorMessage")
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB