initial commit
This commit is contained in:
commit
d84861949f
92 files changed
+8425
No files matched your search
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 <img src="...">
|
||||
*
|
||||
* @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"
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) } }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 |
Binary file not shown.
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
// }
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user