initial commit - source
This commit is contained in:
1 parent
003b5501f0
commit
e89bb29070
45 files changed
+5997
No files matched your search
@@ -0,0 +1,79 @@
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.composeHotReload)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
alias(libs.plugins.koin.compiler)
|
||||
alias(masterLibs.plugins.versionPlugin)
|
||||
}
|
||||
|
||||
group = "net.keinekekse.app"
|
||||
version = libs.versions.composeAppVersion.get()
|
||||
description = "Compose Multiplatform Test-Client"
|
||||
extra["displayname"] = "Books App"
|
||||
extra["copyright"] = "Copyright (c) 2026 Hartmut Bauer"
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
// @OptIn(ExperimentalWasmDsl::class)
|
||||
// wasmJs {
|
||||
// browser()
|
||||
// binaries.executable()
|
||||
// }
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(masterLibs.utilLibrary)
|
||||
implementation(libs.bundles.kotlin.compose)
|
||||
implementation(libs.ktor.client)
|
||||
implementation(libs.ktor.clientCio)
|
||||
implementation(libs.ktor.clientAuth)
|
||||
implementation(libs.ktor.clientLogging)
|
||||
implementation(libs.ktor.clientContentNegotiation)
|
||||
implementation(libs.ktor.serializationJson)
|
||||
implementation(libs.ktor.serverCore)
|
||||
implementation(libs.ktor.serverCio)
|
||||
implementation(project.dependencies.platform(libs.koin.bom))
|
||||
implementation(libs.koin.core)
|
||||
implementation(libs.koin.annotations)
|
||||
implementation(libs.koin.compose)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
// implementation(libs.koin.compose.viewmodel.navigation)
|
||||
implementation(projects.shared)
|
||||
}
|
||||
// commonTest.dependencies {
|
||||
// implementation(libs.kotlin.test)
|
||||
// }
|
||||
jvmMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutinesSwing)
|
||||
implementation(libs.ktor.clientJava) // ??
|
||||
// implementation(libs.ktor.serverCio)
|
||||
// implementation(libs.ktor.serverCore)
|
||||
// implementation(libs.ktor.serverNetty)
|
||||
implementation(libs.ktor.serverAuthJwt) // nicht f. js, wasmJs
|
||||
}
|
||||
// wasmJsMain.dependencies {
|
||||
// implementation(libs.ktor.clientJs)
|
||||
// }
|
||||
|
||||
}
|
||||
jvmToolchain(masterLibs.versions.javaVersion.get().toInt())
|
||||
}
|
||||
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "net.keinekekse.app.MainKt"
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = project.name
|
||||
packageVersion = project.version.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#auto generated
|
||||
#Tue Feb 03 00:28:14 CET 2026
|
||||
build.number=174
|
||||
@@ -0,0 +1,44 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="450dp"
|
||||
android:height="450dp"
|
||||
android:viewportWidth="64"
|
||||
android:viewportHeight="64">
|
||||
<path
|
||||
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
|
||||
android:fillColor="#6075f2"/>
|
||||
<path
|
||||
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
|
||||
android:fillColor="#6b57ff"/>
|
||||
<path
|
||||
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:centerX="23.131"
|
||||
android:centerY="18.441"
|
||||
android:gradientRadius="42.132"
|
||||
android:type="radial">
|
||||
<item android:offset="0" android:color="#FF5383EC"/>
|
||||
<item android:offset="0.867" android:color="#FF7F52FF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="44.172"
|
||||
android:startY="4.377"
|
||||
android:endX="17.973"
|
||||
android:endY="34.035"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF33C3FF"/>
|
||||
<item android:offset="0.878" android:color="#FF5383EC"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,86 @@
|
||||
package net.keinekekse.app
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.safeContentPadding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import net.keinekekse.app.books.generated.resources.Res
|
||||
import net.keinekekse.app.books.generated.resources.compose_multiplatform
|
||||
import net.keinekekse.app.books.ui.theme.AppTheme
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
/*MaterialTheme*/
|
||||
/*
|
||||
eigenes Theme -> https://material-foundation.github.io/material-theme-builder/
|
||||
den Download in den source code übernehmen (in Theme.kt sind Anpassungen nötig)
|
||||
per default hat die generierte Klasse den Namen AppTheme.
|
||||
*/
|
||||
AppTheme {
|
||||
val test = koinInject<TestAuth>()
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.safeContentPadding()
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Button(onClick = { showContent = !showContent }) {
|
||||
Text("Click me!")
|
||||
|
||||
}
|
||||
Button(onClick = {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
println("requesting '/test'")
|
||||
test.request("/test")
|
||||
}
|
||||
}) {
|
||||
Text("Test Token Refresh")
|
||||
}
|
||||
AnimatedVisibility(showContent) {
|
||||
val greeting = remember { Greeting().greet() }
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(painterResource(Res.drawable.compose_multiplatform), null)
|
||||
Text("Compose: $greeting")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Single
|
||||
class TestAuth(val httpClient: HttpClient) {
|
||||
|
||||
private val srv = "https://kas.keinekekse.net/"
|
||||
suspend fun request(url: String) {
|
||||
println("connecting")
|
||||
val response = httpClient.get(srv + url)
|
||||
println("call to $url")
|
||||
println(response.status)
|
||||
println(response.bodyAsText())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package net.keinekekse.app.books
|
||||
|
||||
import net.keinekekse.version.Version
|
||||
|
||||
object VERSION: Version {
|
||||
override val version = "1.0.0"
|
||||
override val fullVersion = "1.0.0+build.174"
|
||||
override val major = 1
|
||||
override val minor = 0
|
||||
override val patch = 0
|
||||
override val preRelease = ""
|
||||
override val buildMetadata = "build.174"
|
||||
override val buildDate = "2026-02-02T23:28:14.786916Z"
|
||||
override val displayName = "Books App"
|
||||
override val copyright = "Copyright (c) 2026 Hartmut Bauer"
|
||||
override val name = "books"
|
||||
override val description = "Compose Multiplatform Test-Client"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package net.keinekekse.app.books.di
|
||||
|
||||
import org.koin.core.annotation.KoinApplication
|
||||
|
||||
@KoinApplication
|
||||
object KoinApp
|
||||
@@ -0,0 +1,13 @@
|
||||
package net.keinekekse.app.books.di
|
||||
|
||||
import org.koin.core.annotation.*
|
||||
|
||||
@Module
|
||||
@Configuration
|
||||
@ComponentScan("net.keinekekse.app")
|
||||
class KoinModules {
|
||||
|
||||
@Named("AppString")
|
||||
@Factory
|
||||
fun provideSomeString() = "Injected App string"
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package net.keinekekse.app.books.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val primaryLight = Color(0xFF1C6585)
|
||||
val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
val primaryContainerLight = Color(0xFFC3E8FF)
|
||||
val onPrimaryContainerLight = Color(0xFF004C69)
|
||||
val secondaryLight = Color(0xFF4E616D)
|
||||
val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLight = Color(0xFFD1E5F4)
|
||||
val onSecondaryContainerLight = Color(0xFF364955)
|
||||
val tertiaryLight = Color(0xFF605A7D)
|
||||
val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLight = Color(0xFFE6DEFF)
|
||||
val onTertiaryContainerLight = Color(0xFF484264)
|
||||
val errorLight = Color(0xFFBA1A1A)
|
||||
val onErrorLight = Color(0xFFFFFFFF)
|
||||
val errorContainerLight = Color(0xFFFFDAD6)
|
||||
val onErrorContainerLight = Color(0xFF93000A)
|
||||
val backgroundLight = Color(0xFFF6FAFE)
|
||||
val onBackgroundLight = Color(0xFF181C1F)
|
||||
val surfaceLight = Color(0xFFF6FAFE)
|
||||
val onSurfaceLight = Color(0xFF181C1F)
|
||||
val surfaceVariantLight = Color(0xFFDCE3E9)
|
||||
val onSurfaceVariantLight = Color(0xFF41484D)
|
||||
val outlineLight = Color(0xFF71787D)
|
||||
val outlineVariantLight = Color(0xFFC0C7CD)
|
||||
val scrimLight = Color(0xFF000000)
|
||||
val inverseSurfaceLight = Color(0xFF2C3134)
|
||||
val inverseOnSurfaceLight = Color(0xFFEDF1F5)
|
||||
val inversePrimaryLight = Color(0xFF8FCEF3)
|
||||
val surfaceDimLight = Color(0xFFD6DADF)
|
||||
val surfaceBrightLight = Color(0xFFF6FAFE)
|
||||
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLight = Color(0xFFF0F4F8)
|
||||
val surfaceContainerLight = Color(0xFFEAEEF2)
|
||||
val surfaceContainerHighLight = Color(0xFFE5E9ED)
|
||||
val surfaceContainerHighestLight = Color(0xFFDFE3E7)
|
||||
|
||||
val primaryLightMediumContrast = Color(0xFF003B51)
|
||||
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val primaryContainerLightMediumContrast = Color(0xFF307495)
|
||||
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val secondaryLightMediumContrast = Color(0xFF263844)
|
||||
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLightMediumContrast = Color(0xFF5D707C)
|
||||
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryLightMediumContrast = Color(0xFF373252)
|
||||
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLightMediumContrast = Color(0xFF6F688C)
|
||||
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val errorLightMediumContrast = Color(0xFF740006)
|
||||
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val errorContainerLightMediumContrast = Color(0xFFCF2C27)
|
||||
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val backgroundLightMediumContrast = Color(0xFFF6FAFE)
|
||||
val onBackgroundLightMediumContrast = Color(0xFF181C1F)
|
||||
val surfaceLightMediumContrast = Color(0xFFF6FAFE)
|
||||
val onSurfaceLightMediumContrast = Color(0xFF0D1215)
|
||||
val surfaceVariantLightMediumContrast = Color(0xFFDCE3E9)
|
||||
val onSurfaceVariantLightMediumContrast = Color(0xFF30373C)
|
||||
val outlineLightMediumContrast = Color(0xFF4C5359)
|
||||
val outlineVariantLightMediumContrast = Color(0xFF676E73)
|
||||
val scrimLightMediumContrast = Color(0xFF000000)
|
||||
val inverseSurfaceLightMediumContrast = Color(0xFF2C3134)
|
||||
val inverseOnSurfaceLightMediumContrast = Color(0xFFEDF1F5)
|
||||
val inversePrimaryLightMediumContrast = Color(0xFF8FCEF3)
|
||||
val surfaceDimLightMediumContrast = Color(0xFFC3C7CB)
|
||||
val surfaceBrightLightMediumContrast = Color(0xFFF6FAFE)
|
||||
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLightMediumContrast = Color(0xFFF0F4F8)
|
||||
val surfaceContainerLightMediumContrast = Color(0xFFE5E9ED)
|
||||
val surfaceContainerHighLightMediumContrast = Color(0xFFD9DDE1)
|
||||
val surfaceContainerHighestLightMediumContrast = Color(0xFFCED2D6)
|
||||
|
||||
val primaryLightHighContrast = Color(0xFF003043)
|
||||
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val primaryContainerLightHighContrast = Color(0xFF004F6C)
|
||||
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val secondaryLightHighContrast = Color(0xFF1C2E39)
|
||||
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLightHighContrast = Color(0xFF394C57)
|
||||
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryLightHighContrast = Color(0xFF2D2847)
|
||||
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLightHighContrast = Color(0xFF4B4566)
|
||||
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val errorLightHighContrast = Color(0xFF600004)
|
||||
val onErrorLightHighContrast = Color(0xFFFFFFFF)
|
||||
val errorContainerLightHighContrast = Color(0xFF98000A)
|
||||
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val backgroundLightHighContrast = Color(0xFFF6FAFE)
|
||||
val onBackgroundLightHighContrast = Color(0xFF181C1F)
|
||||
val surfaceLightHighContrast = Color(0xFFF6FAFE)
|
||||
val onSurfaceLightHighContrast = Color(0xFF000000)
|
||||
val surfaceVariantLightHighContrast = Color(0xFFDCE3E9)
|
||||
val onSurfaceVariantLightHighContrast = Color(0xFF000000)
|
||||
val outlineLightHighContrast = Color(0xFF262D32)
|
||||
val outlineVariantLightHighContrast = Color(0xFF434A4F)
|
||||
val scrimLightHighContrast = Color(0xFF000000)
|
||||
val inverseSurfaceLightHighContrast = Color(0xFF2C3134)
|
||||
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
|
||||
val inversePrimaryLightHighContrast = Color(0xFF8FCEF3)
|
||||
val surfaceDimLightHighContrast = Color(0xFFB5B9BD)
|
||||
val surfaceBrightLightHighContrast = Color(0xFFF6FAFE)
|
||||
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLightHighContrast = Color(0xFFEDF1F5)
|
||||
val surfaceContainerLightHighContrast = Color(0xFFDFE3E7)
|
||||
val surfaceContainerHighLightHighContrast = Color(0xFFD1D5D9)
|
||||
val surfaceContainerHighestLightHighContrast = Color(0xFFC3C7CB)
|
||||
|
||||
val primaryDark = Color(0xFF8FCEF3)
|
||||
val onPrimaryDark = Color(0xFF003549)
|
||||
val primaryContainerDark = Color(0xFF004C69)
|
||||
val onPrimaryContainerDark = Color(0xFFC3E8FF)
|
||||
val secondaryDark = Color(0xFFB5C9D7)
|
||||
val onSecondaryDark = Color(0xFF20333E)
|
||||
val secondaryContainerDark = Color(0xFF364955)
|
||||
val onSecondaryContainerDark = Color(0xFFD1E5F4)
|
||||
val tertiaryDark = Color(0xFFCAC1EA)
|
||||
val onTertiaryDark = Color(0xFF322C4C)
|
||||
val tertiaryContainerDark = Color(0xFF484264)
|
||||
val onTertiaryContainerDark = Color(0xFFE6DEFF)
|
||||
val errorDark = Color(0xFFFFB4AB)
|
||||
val onErrorDark = Color(0xFF690005)
|
||||
val errorContainerDark = Color(0xFF93000A)
|
||||
val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
val backgroundDark = Color(0xFF0F1417)
|
||||
val onBackgroundDark = Color(0xFFDFE3E7)
|
||||
val surfaceDark = Color(0xFF0F1417)
|
||||
val onSurfaceDark = Color(0xFFDFE3E7)
|
||||
val surfaceVariantDark = Color(0xFF41484D)
|
||||
val onSurfaceVariantDark = Color(0xFFC0C7CD)
|
||||
val outlineDark = Color(0xFF8A9297)
|
||||
val outlineVariantDark = Color(0xFF41484D)
|
||||
val scrimDark = Color(0xFF000000)
|
||||
val inverseSurfaceDark = Color(0xFFDFE3E7)
|
||||
val inverseOnSurfaceDark = Color(0xFF2C3134)
|
||||
val inversePrimaryDark = Color(0xFF1C6585)
|
||||
val surfaceDimDark = Color(0xFF0F1417)
|
||||
val surfaceBrightDark = Color(0xFF353A3D)
|
||||
val surfaceContainerLowestDark = Color(0xFF0A0F12)
|
||||
val surfaceContainerLowDark = Color(0xFF181C1F)
|
||||
val surfaceContainerDark = Color(0xFF1C2023)
|
||||
val surfaceContainerHighDark = Color(0xFF262B2E)
|
||||
val surfaceContainerHighestDark = Color(0xFF313539)
|
||||
|
||||
val primaryDarkMediumContrast = Color(0xFFB5E2FF)
|
||||
val onPrimaryDarkMediumContrast = Color(0xFF00293A)
|
||||
val primaryContainerDarkMediumContrast = Color(0xFF5898BB)
|
||||
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val secondaryDarkMediumContrast = Color(0xFFCBDFED)
|
||||
val onSecondaryDarkMediumContrast = Color(0xFF152832)
|
||||
val secondaryContainerDarkMediumContrast = Color(0xFF8093A0)
|
||||
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val tertiaryDarkMediumContrast = Color(0xFFE0D7FF)
|
||||
val onTertiaryDarkMediumContrast = Color(0xFF272140)
|
||||
val tertiaryContainerDarkMediumContrast = Color(0xFF938CB2)
|
||||
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val errorDarkMediumContrast = Color(0xFFFFD2CC)
|
||||
val onErrorDarkMediumContrast = Color(0xFF540003)
|
||||
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
|
||||
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val backgroundDarkMediumContrast = Color(0xFF0F1417)
|
||||
val onBackgroundDarkMediumContrast = Color(0xFFDFE3E7)
|
||||
val surfaceDarkMediumContrast = Color(0xFF0F1417)
|
||||
val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF)
|
||||
val surfaceVariantDarkMediumContrast = Color(0xFF41484D)
|
||||
val onSurfaceVariantDarkMediumContrast = Color(0xFFD6DDE3)
|
||||
val outlineDarkMediumContrast = Color(0xFFACB3B9)
|
||||
val outlineVariantDarkMediumContrast = Color(0xFF8A9197)
|
||||
val scrimDarkMediumContrast = Color(0xFF000000)
|
||||
val inverseSurfaceDarkMediumContrast = Color(0xFFDFE3E7)
|
||||
val inverseOnSurfaceDarkMediumContrast = Color(0xFF262B2E)
|
||||
val inversePrimaryDarkMediumContrast = Color(0xFF004E6A)
|
||||
val surfaceDimDarkMediumContrast = Color(0xFF0F1417)
|
||||
val surfaceBrightDarkMediumContrast = Color(0xFF404549)
|
||||
val surfaceContainerLowestDarkMediumContrast = Color(0xFF04080B)
|
||||
val surfaceContainerLowDarkMediumContrast = Color(0xFF1A1E21)
|
||||
val surfaceContainerDarkMediumContrast = Color(0xFF24282C)
|
||||
val surfaceContainerHighDarkMediumContrast = Color(0xFF2E3337)
|
||||
val surfaceContainerHighestDarkMediumContrast = Color(0xFF3A3E42)
|
||||
|
||||
val primaryDarkHighContrast = Color(0xFFE1F2FF)
|
||||
val onPrimaryDarkHighContrast = Color(0xFF000000)
|
||||
val primaryContainerDarkHighContrast = Color(0xFF8BCAEF)
|
||||
val onPrimaryContainerDarkHighContrast = Color(0xFF000D15)
|
||||
val secondaryDarkHighContrast = Color(0xFFE1F2FF)
|
||||
val onSecondaryDarkHighContrast = Color(0xFF000000)
|
||||
val secondaryContainerDarkHighContrast = Color(0xFFB1C5D3)
|
||||
val onSecondaryContainerDarkHighContrast = Color(0xFF000D15)
|
||||
val tertiaryDarkHighContrast = Color(0xFFF3EDFF)
|
||||
val onTertiaryDarkHighContrast = Color(0xFF000000)
|
||||
val tertiaryContainerDarkHighContrast = Color(0xFFC6BDE5)
|
||||
val onTertiaryContainerDarkHighContrast = Color(0xFF0C0625)
|
||||
val errorDarkHighContrast = Color(0xFFFFECE9)
|
||||
val onErrorDarkHighContrast = Color(0xFF000000)
|
||||
val errorContainerDarkHighContrast = Color(0xFFFFAEA4)
|
||||
val onErrorContainerDarkHighContrast = Color(0xFF220001)
|
||||
val backgroundDarkHighContrast = Color(0xFF0F1417)
|
||||
val onBackgroundDarkHighContrast = Color(0xFFDFE3E7)
|
||||
val surfaceDarkHighContrast = Color(0xFF0F1417)
|
||||
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
|
||||
val surfaceVariantDarkHighContrast = Color(0xFF41484D)
|
||||
val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF)
|
||||
val outlineDarkHighContrast = Color(0xFFEAF1F7)
|
||||
val outlineVariantDarkHighContrast = Color(0xFFBCC3C9)
|
||||
val scrimDarkHighContrast = Color(0xFF000000)
|
||||
val inverseSurfaceDarkHighContrast = Color(0xFFDFE3E7)
|
||||
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
|
||||
val inversePrimaryDarkHighContrast = Color(0xFF004E6A)
|
||||
val surfaceDimDarkHighContrast = Color(0xFF0F1417)
|
||||
val surfaceBrightDarkHighContrast = Color(0xFF4C5154)
|
||||
val surfaceContainerLowestDarkHighContrast = Color(0xFF000000)
|
||||
val surfaceContainerLowDarkHighContrast = Color(0xFF1C2023)
|
||||
val surfaceContainerDarkHighContrast = Color(0xFF2C3134)
|
||||
val surfaceContainerHighDarkHighContrast = Color(0xFF373C3F)
|
||||
val surfaceContainerHighestDarkHighContrast = Color(0xFF43474B)
|
||||
@@ -0,0 +1,278 @@
|
||||
package net.keinekekse.app.books.ui.theme
|
||||
|
||||
//import android.os.Build
|
||||
//import androidx.compose.material3.dynamicDarkColorScheme
|
||||
//import androidx.compose.material3.dynamicLightColorScheme
|
||||
//import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val lightScheme = lightColorScheme(
|
||||
primary = primaryLight,
|
||||
onPrimary = onPrimaryLight,
|
||||
primaryContainer = primaryContainerLight,
|
||||
onPrimaryContainer = onPrimaryContainerLight,
|
||||
secondary = secondaryLight,
|
||||
onSecondary = onSecondaryLight,
|
||||
secondaryContainer = secondaryContainerLight,
|
||||
onSecondaryContainer = onSecondaryContainerLight,
|
||||
tertiary = tertiaryLight,
|
||||
onTertiary = onTertiaryLight,
|
||||
tertiaryContainer = tertiaryContainerLight,
|
||||
onTertiaryContainer = onTertiaryContainerLight,
|
||||
error = errorLight,
|
||||
onError = onErrorLight,
|
||||
errorContainer = errorContainerLight,
|
||||
onErrorContainer = onErrorContainerLight,
|
||||
background = backgroundLight,
|
||||
onBackground = onBackgroundLight,
|
||||
surface = surfaceLight,
|
||||
onSurface = onSurfaceLight,
|
||||
surfaceVariant = surfaceVariantLight,
|
||||
onSurfaceVariant = onSurfaceVariantLight,
|
||||
outline = outlineLight,
|
||||
outlineVariant = outlineVariantLight,
|
||||
scrim = scrimLight,
|
||||
inverseSurface = inverseSurfaceLight,
|
||||
inverseOnSurface = inverseOnSurfaceLight,
|
||||
inversePrimary = inversePrimaryLight,
|
||||
surfaceDim = surfaceDimLight,
|
||||
surfaceBright = surfaceBrightLight,
|
||||
surfaceContainerLowest = surfaceContainerLowestLight,
|
||||
surfaceContainerLow = surfaceContainerLowLight,
|
||||
surfaceContainer = surfaceContainerLight,
|
||||
surfaceContainerHigh = surfaceContainerHighLight,
|
||||
surfaceContainerHighest = surfaceContainerHighestLight,
|
||||
)
|
||||
|
||||
private val darkScheme = darkColorScheme(
|
||||
primary = primaryDark,
|
||||
onPrimary = onPrimaryDark,
|
||||
primaryContainer = primaryContainerDark,
|
||||
onPrimaryContainer = onPrimaryContainerDark,
|
||||
secondary = secondaryDark,
|
||||
onSecondary = onSecondaryDark,
|
||||
secondaryContainer = secondaryContainerDark,
|
||||
onSecondaryContainer = onSecondaryContainerDark,
|
||||
tertiary = tertiaryDark,
|
||||
onTertiary = onTertiaryDark,
|
||||
tertiaryContainer = tertiaryContainerDark,
|
||||
onTertiaryContainer = onTertiaryContainerDark,
|
||||
error = errorDark,
|
||||
onError = onErrorDark,
|
||||
errorContainer = errorContainerDark,
|
||||
onErrorContainer = onErrorContainerDark,
|
||||
background = backgroundDark,
|
||||
onBackground = onBackgroundDark,
|
||||
surface = surfaceDark,
|
||||
onSurface = onSurfaceDark,
|
||||
surfaceVariant = surfaceVariantDark,
|
||||
onSurfaceVariant = onSurfaceVariantDark,
|
||||
outline = outlineDark,
|
||||
outlineVariant = outlineVariantDark,
|
||||
scrim = scrimDark,
|
||||
inverseSurface = inverseSurfaceDark,
|
||||
inverseOnSurface = inverseOnSurfaceDark,
|
||||
inversePrimary = inversePrimaryDark,
|
||||
surfaceDim = surfaceDimDark,
|
||||
surfaceBright = surfaceBrightDark,
|
||||
surfaceContainerLowest = surfaceContainerLowestDark,
|
||||
surfaceContainerLow = surfaceContainerLowDark,
|
||||
surfaceContainer = surfaceContainerDark,
|
||||
surfaceContainerHigh = surfaceContainerHighDark,
|
||||
surfaceContainerHighest = surfaceContainerHighestDark,
|
||||
)
|
||||
|
||||
private val mediumContrastLightColorScheme = lightColorScheme(
|
||||
primary = primaryLightMediumContrast,
|
||||
onPrimary = onPrimaryLightMediumContrast,
|
||||
primaryContainer = primaryContainerLightMediumContrast,
|
||||
onPrimaryContainer = onPrimaryContainerLightMediumContrast,
|
||||
secondary = secondaryLightMediumContrast,
|
||||
onSecondary = onSecondaryLightMediumContrast,
|
||||
secondaryContainer = secondaryContainerLightMediumContrast,
|
||||
onSecondaryContainer = onSecondaryContainerLightMediumContrast,
|
||||
tertiary = tertiaryLightMediumContrast,
|
||||
onTertiary = onTertiaryLightMediumContrast,
|
||||
tertiaryContainer = tertiaryContainerLightMediumContrast,
|
||||
onTertiaryContainer = onTertiaryContainerLightMediumContrast,
|
||||
error = errorLightMediumContrast,
|
||||
onError = onErrorLightMediumContrast,
|
||||
errorContainer = errorContainerLightMediumContrast,
|
||||
onErrorContainer = onErrorContainerLightMediumContrast,
|
||||
background = backgroundLightMediumContrast,
|
||||
onBackground = onBackgroundLightMediumContrast,
|
||||
surface = surfaceLightMediumContrast,
|
||||
onSurface = onSurfaceLightMediumContrast,
|
||||
surfaceVariant = surfaceVariantLightMediumContrast,
|
||||
onSurfaceVariant = onSurfaceVariantLightMediumContrast,
|
||||
outline = outlineLightMediumContrast,
|
||||
outlineVariant = outlineVariantLightMediumContrast,
|
||||
scrim = scrimLightMediumContrast,
|
||||
inverseSurface = inverseSurfaceLightMediumContrast,
|
||||
inverseOnSurface = inverseOnSurfaceLightMediumContrast,
|
||||
inversePrimary = inversePrimaryLightMediumContrast,
|
||||
surfaceDim = surfaceDimLightMediumContrast,
|
||||
surfaceBright = surfaceBrightLightMediumContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestLightMediumContrast,
|
||||
surfaceContainerLow = surfaceContainerLowLightMediumContrast,
|
||||
surfaceContainer = surfaceContainerLightMediumContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighLightMediumContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
|
||||
)
|
||||
|
||||
private val highContrastLightColorScheme = lightColorScheme(
|
||||
primary = primaryLightHighContrast,
|
||||
onPrimary = onPrimaryLightHighContrast,
|
||||
primaryContainer = primaryContainerLightHighContrast,
|
||||
onPrimaryContainer = onPrimaryContainerLightHighContrast,
|
||||
secondary = secondaryLightHighContrast,
|
||||
onSecondary = onSecondaryLightHighContrast,
|
||||
secondaryContainer = secondaryContainerLightHighContrast,
|
||||
onSecondaryContainer = onSecondaryContainerLightHighContrast,
|
||||
tertiary = tertiaryLightHighContrast,
|
||||
onTertiary = onTertiaryLightHighContrast,
|
||||
tertiaryContainer = tertiaryContainerLightHighContrast,
|
||||
onTertiaryContainer = onTertiaryContainerLightHighContrast,
|
||||
error = errorLightHighContrast,
|
||||
onError = onErrorLightHighContrast,
|
||||
errorContainer = errorContainerLightHighContrast,
|
||||
onErrorContainer = onErrorContainerLightHighContrast,
|
||||
background = backgroundLightHighContrast,
|
||||
onBackground = onBackgroundLightHighContrast,
|
||||
surface = surfaceLightHighContrast,
|
||||
onSurface = onSurfaceLightHighContrast,
|
||||
surfaceVariant = surfaceVariantLightHighContrast,
|
||||
onSurfaceVariant = onSurfaceVariantLightHighContrast,
|
||||
outline = outlineLightHighContrast,
|
||||
outlineVariant = outlineVariantLightHighContrast,
|
||||
scrim = scrimLightHighContrast,
|
||||
inverseSurface = inverseSurfaceLightHighContrast,
|
||||
inverseOnSurface = inverseOnSurfaceLightHighContrast,
|
||||
inversePrimary = inversePrimaryLightHighContrast,
|
||||
surfaceDim = surfaceDimLightHighContrast,
|
||||
surfaceBright = surfaceBrightLightHighContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestLightHighContrast,
|
||||
surfaceContainerLow = surfaceContainerLowLightHighContrast,
|
||||
surfaceContainer = surfaceContainerLightHighContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighLightHighContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
|
||||
)
|
||||
|
||||
private val mediumContrastDarkColorScheme = darkColorScheme(
|
||||
primary = primaryDarkMediumContrast,
|
||||
onPrimary = onPrimaryDarkMediumContrast,
|
||||
primaryContainer = primaryContainerDarkMediumContrast,
|
||||
onPrimaryContainer = onPrimaryContainerDarkMediumContrast,
|
||||
secondary = secondaryDarkMediumContrast,
|
||||
onSecondary = onSecondaryDarkMediumContrast,
|
||||
secondaryContainer = secondaryContainerDarkMediumContrast,
|
||||
onSecondaryContainer = onSecondaryContainerDarkMediumContrast,
|
||||
tertiary = tertiaryDarkMediumContrast,
|
||||
onTertiary = onTertiaryDarkMediumContrast,
|
||||
tertiaryContainer = tertiaryContainerDarkMediumContrast,
|
||||
onTertiaryContainer = onTertiaryContainerDarkMediumContrast,
|
||||
error = errorDarkMediumContrast,
|
||||
onError = onErrorDarkMediumContrast,
|
||||
errorContainer = errorContainerDarkMediumContrast,
|
||||
onErrorContainer = onErrorContainerDarkMediumContrast,
|
||||
background = backgroundDarkMediumContrast,
|
||||
onBackground = onBackgroundDarkMediumContrast,
|
||||
surface = surfaceDarkMediumContrast,
|
||||
onSurface = onSurfaceDarkMediumContrast,
|
||||
surfaceVariant = surfaceVariantDarkMediumContrast,
|
||||
onSurfaceVariant = onSurfaceVariantDarkMediumContrast,
|
||||
outline = outlineDarkMediumContrast,
|
||||
outlineVariant = outlineVariantDarkMediumContrast,
|
||||
scrim = scrimDarkMediumContrast,
|
||||
inverseSurface = inverseSurfaceDarkMediumContrast,
|
||||
inverseOnSurface = inverseOnSurfaceDarkMediumContrast,
|
||||
inversePrimary = inversePrimaryDarkMediumContrast,
|
||||
surfaceDim = surfaceDimDarkMediumContrast,
|
||||
surfaceBright = surfaceBrightDarkMediumContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast,
|
||||
surfaceContainerLow = surfaceContainerLowDarkMediumContrast,
|
||||
surfaceContainer = surfaceContainerDarkMediumContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighDarkMediumContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
|
||||
)
|
||||
|
||||
private val highContrastDarkColorScheme = darkColorScheme(
|
||||
primary = primaryDarkHighContrast,
|
||||
onPrimary = onPrimaryDarkHighContrast,
|
||||
primaryContainer = primaryContainerDarkHighContrast,
|
||||
onPrimaryContainer = onPrimaryContainerDarkHighContrast,
|
||||
secondary = secondaryDarkHighContrast,
|
||||
onSecondary = onSecondaryDarkHighContrast,
|
||||
secondaryContainer = secondaryContainerDarkHighContrast,
|
||||
onSecondaryContainer = onSecondaryContainerDarkHighContrast,
|
||||
tertiary = tertiaryDarkHighContrast,
|
||||
onTertiary = onTertiaryDarkHighContrast,
|
||||
tertiaryContainer = tertiaryContainerDarkHighContrast,
|
||||
onTertiaryContainer = onTertiaryContainerDarkHighContrast,
|
||||
error = errorDarkHighContrast,
|
||||
onError = onErrorDarkHighContrast,
|
||||
errorContainer = errorContainerDarkHighContrast,
|
||||
onErrorContainer = onErrorContainerDarkHighContrast,
|
||||
background = backgroundDarkHighContrast,
|
||||
onBackground = onBackgroundDarkHighContrast,
|
||||
surface = surfaceDarkHighContrast,
|
||||
onSurface = onSurfaceDarkHighContrast,
|
||||
surfaceVariant = surfaceVariantDarkHighContrast,
|
||||
onSurfaceVariant = onSurfaceVariantDarkHighContrast,
|
||||
outline = outlineDarkHighContrast,
|
||||
outlineVariant = outlineVariantDarkHighContrast,
|
||||
scrim = scrimDarkHighContrast,
|
||||
inverseSurface = inverseSurfaceDarkHighContrast,
|
||||
inverseOnSurface = inverseOnSurfaceDarkHighContrast,
|
||||
inversePrimary = inversePrimaryDarkHighContrast,
|
||||
surfaceDim = surfaceDimDarkHighContrast,
|
||||
surfaceBright = surfaceBrightDarkHighContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestDarkHighContrast,
|
||||
surfaceContainerLow = surfaceContainerLowDarkHighContrast,
|
||||
surfaceContainer = surfaceContainerDarkHighContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighDarkHighContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class ColorFamily(
|
||||
val color: Color,
|
||||
val onColor: Color,
|
||||
val colorContainer: Color,
|
||||
val onColorContainer: Color,
|
||||
)
|
||||
|
||||
val unspecified_scheme = ColorFamily(
|
||||
Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable() () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
// *hb* das gibt Probleme außerhalb von Android
|
||||
// dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
// val context = LocalContext.current
|
||||
// if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
// }
|
||||
|
||||
darkTheme -> darkScheme
|
||||
else -> lightScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = AppTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package net.keinekekse.app.books.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
val AppTypography = Typography()
|
||||
@@ -0,0 +1,507 @@
|
||||
package net.keinekekse.app.auth
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.network.tls.extensions.HashAlgorithm
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.cio.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.util.Digest
|
||||
import io.ktor.util.sha1
|
||||
import io.ktor.utils.io.charsets.Charsets
|
||||
import io.ktor.utils.io.core.Closeable
|
||||
import io.ktor.utils.io.core.toByteArray
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.awt.Desktop
|
||||
import java.net.Socket
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.use
|
||||
|
||||
class Authenticator2(
|
||||
keycloakServerHostname: String,
|
||||
keycloakRealm: String,
|
||||
keycloakClient: String,
|
||||
) : Closeable {
|
||||
private val httpClient = HttpClient {
|
||||
install(ContentNegotiation) { json() }
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 10000
|
||||
connectTimeoutMillis = 5000
|
||||
}
|
||||
}
|
||||
|
||||
private val keyc =
|
||||
KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
|
||||
|
||||
override fun close() = httpClient.close()
|
||||
|
||||
suspend fun authenticate(): AuthResult {
|
||||
val config = AuthRun(keyc)
|
||||
|
||||
if (!testServerConnection(config)) {
|
||||
return AuthResult.Error("ERR001", "Der Anmeldeserver ist nicht erreichbar.", "")
|
||||
}
|
||||
// var server: EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration>? = null
|
||||
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
||||
try {
|
||||
server = createCallbackServer(config).start(wait = false)
|
||||
val port = server.engine.resolvedConnectors().first().port
|
||||
config.buildRedirectUri(port)
|
||||
if (!waitForServer(port)) {
|
||||
return AuthResult.Fatal(
|
||||
"ERR008",
|
||||
"Schwerer Fehler",
|
||||
"Der interne Webserver konnte nicht gestartet werden. Keine Anmeldung möglich."
|
||||
)
|
||||
}
|
||||
|
||||
openAuthorizationInBrowser(config)
|
||||
|
||||
return withTimeout(5.minutes) {
|
||||
val resolvedTokens = config.resultDeferred.await()
|
||||
resolvedTokens
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return AuthResult.Error(
|
||||
"ERR002",
|
||||
"Bei der Anmeldung ist ein Fehler aufgetreten",
|
||||
e.message ?: "Unbekannter Fehler"
|
||||
)
|
||||
} finally {
|
||||
server?.stop(1000, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestRefreshToken(tokens: BearerTokens): AuthResult {
|
||||
val config = AuthRun(keyc)
|
||||
|
||||
val response = httpClient.submitForm(
|
||||
url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.tokenEndpoint
|
||||
}.toString(),
|
||||
formParameters = parameters {
|
||||
append("grant_type", "refresh_token")
|
||||
append("client_id", config.kc.keycloakClient)
|
||||
append("refresh_token", tokens.refreshToken ?: "")
|
||||
}
|
||||
)
|
||||
// {
|
||||
// headers { append("Authorization", "Bearer ${tokens.accessToken}") }
|
||||
// }
|
||||
|
||||
return validateRequestTokenResponse(response)
|
||||
}
|
||||
|
||||
private fun isPortOpen(port: Int): Boolean =
|
||||
try {
|
||||
// TODO Socket ist nicht portabel (JVM only)
|
||||
Socket(KeycloakConfig.LOCALHOST, port).use { true }
|
||||
} catch (e: Exception) {
|
||||
// Port is closed or unreachable
|
||||
false
|
||||
}
|
||||
|
||||
private suspend fun waitForServer(port: Int, timeoutSeconds: Int = 3): Boolean {
|
||||
repeat(timeoutSeconds * 10) {
|
||||
if (isPortOpen(port)) return true
|
||||
delay(100)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun openAuthorizationInBrowser(config: AuthRun) {
|
||||
val authorizationUrl = buildAuthorizationUrl(config)
|
||||
|
||||
// TODO momentan nur für JVM, Rest ist noch zu eruieren
|
||||
if (!Desktop.isDesktopSupported()) {
|
||||
throw IllegalStateException("Desktop API not supported (cannot open browser automatically).")
|
||||
}
|
||||
val desktop = Desktop.getDesktop()
|
||||
if (!desktop.isSupported(Desktop.Action.BROWSE)) {
|
||||
throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).")
|
||||
}
|
||||
|
||||
desktop.browse(authorizationUrl.toURI())
|
||||
}
|
||||
|
||||
private fun buildAuthorizationUrl(config: AuthRun): Url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.authorizationEndpoint
|
||||
parameters.append("response_type", "code")
|
||||
parameters.append("client_id", config.kc.keycloakClient)
|
||||
parameters.append("redirect_uri", config.redirectUri)
|
||||
parameters.append("scope", KeycloakConfig.SCOPE)
|
||||
parameters.append("state", config.csrfProtectionState)
|
||||
parameters.append("code_challenge", config.codeChallenge)
|
||||
parameters.append("code_challenge_method", KeycloakConfig.PKCE_METHOD)
|
||||
}
|
||||
|
||||
private fun createCallbackServer(config: AuthRun) =
|
||||
embeddedServer(CIO, port = 0, host = KeycloakConfig.LOCALHOST) {
|
||||
routing {
|
||||
get(KeycloakConfig.CALLBACK_PATH) {
|
||||
// val serverPort = engine.resolvedConnectors().first().port
|
||||
val code = call.request.queryParameters["code"]
|
||||
|
||||
var authResult =
|
||||
validateAuthorizationRequest(call.request, config.csrfProtectionState)
|
||||
when (authResult) {
|
||||
is AuthResult.Success -> {
|
||||
if (code == null) {
|
||||
authResult = AuthResult.Fatal(
|
||||
"ERR003",
|
||||
"Schwerer Fehler",
|
||||
"Der Anmeldeserver lieferte ungültige Daten. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
)
|
||||
} else {
|
||||
authResult = requestTokens(config, code)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
var text = ""
|
||||
var status = HttpStatusCode.BadRequest
|
||||
when (authResult) {
|
||||
is AuthResult.Success -> {
|
||||
text =
|
||||
"<html><body><h1>Login erfolgreich! Du kannst das Fenster schließen.</h1></body></html>"
|
||||
status = HttpStatusCode.OK
|
||||
}
|
||||
|
||||
is AuthResult.Error -> {
|
||||
text = "<html><body><h1>Fehler bei der Anmeldung.</h1><h2>${
|
||||
htmlEscape(authResult.message)
|
||||
}</h2><h3>${htmlEscape(authResult.details ?: "")}</h3></body></html>"
|
||||
status = HttpStatusCode.BadRequest
|
||||
}
|
||||
|
||||
is AuthResult.Fatal -> {
|
||||
text =
|
||||
"<html><body><h1>Schwerer Fehler.</h1><h2>${htmlEscape(authResult.message)}</h2><h3>${
|
||||
htmlEscape(authResult.details ?: "")
|
||||
}</h3></body></html>"
|
||||
status = HttpStatusCode.InternalServerError
|
||||
}
|
||||
}
|
||||
call.respondText(text, contentType = ContentType.Text.Html, status = status)
|
||||
|
||||
if (!config.resultDeferred.isCompleted)
|
||||
config.resultDeferred.complete(authResult)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun htmlEscape(value: String): String =
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
|
||||
private fun validateAuthorizationRequest(
|
||||
request: RoutingRequest,
|
||||
csrfProtectionState: String,
|
||||
): AuthResult {
|
||||
/*
|
||||
- state != csrfProt -> Fehler: Kommunikation mit dem Anmeldeserver hat nicht geklappt,
|
||||
könnte auf Malware hindeuten, Abbruch
|
||||
throw Fatal error authenticating user -> exit app
|
||||
- wenn error =! null -> error + error_description anzeigen
|
||||
rc NULL == keine auth -> app muss entscheiden
|
||||
- code == null -> Fehler: Server liefert ungültige Daten,
|
||||
könnte auf Probleme bei der Netzwerkverbindung oder beim Anmeldeserver hindeuten
|
||||
rc NULL -> app
|
||||
*/
|
||||
if (request.queryParameters["error"] != null) {
|
||||
return AuthResult.Error(
|
||||
"ERR004",
|
||||
"Fehler bei der Anmeldung",
|
||||
"${request.queryParameters["error"]}: ${request.queryParameters["error_description"] ?: ""}"
|
||||
)
|
||||
}
|
||||
if (request.queryParameters["state"] != csrfProtectionState) {
|
||||
return AuthResult.Fatal(
|
||||
"ERR005",
|
||||
"Schwerer Fehler",
|
||||
"CSRF-Fehler in der Kommunikation mit dem Anmeldeserver. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
)
|
||||
}
|
||||
return AuthResult.Success(BearerTokens("", "")) // hier ist nur Success wichtig
|
||||
}
|
||||
|
||||
/* Antwort von Keycloak:
|
||||
{
|
||||
"access_token": "eyJhbG ...
|
||||
"expires_in": 300,
|
||||
"refresh_expires_in": 0,
|
||||
"refresh_token": "eyJhb ...
|
||||
"token_type": "Bearer",
|
||||
"id_token": "eyJ ...
|
||||
"not-before-policy": 1769817270,
|
||||
"session_state": "YhlNjIuW-lk1aQwwD9xm4edJ",
|
||||
"scope": "openid profile email"
|
||||
}
|
||||
*/
|
||||
|
||||
@Serializable
|
||||
data class TokenResponse(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("expires_in") val expiresIn: Int,
|
||||
@SerialName("refresh_expires_in") val refreshExpiresIn: Int,
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
@SerialName("token_type") val tokenType: String,
|
||||
@SerialName("id_token") val idToken: String?,
|
||||
@SerialName("not-before-policy") val notBeforePolicy: Int?,
|
||||
@SerialName("session_state") val sessionState: String?,
|
||||
val scope: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TokenErrorResponse(
|
||||
@SerialName("error") val error: String,
|
||||
@SerialName("error_description") val errorDescription: String?,
|
||||
@SerialName("session_state") val sessionState: String?,
|
||||
)
|
||||
|
||||
private suspend fun requestTokens(config: AuthRun, authCode: String): AuthResult {
|
||||
val response: HttpResponse = httpClient.submitForm(
|
||||
url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.tokenEndpoint
|
||||
}.toString(),
|
||||
formParameters = parameters {
|
||||
append("grant_type", "authorization_code")
|
||||
append("client_id", config.kc.keycloakClient)
|
||||
append("code", authCode)
|
||||
append("redirect_uri", config.redirectUri)
|
||||
append("code_verifier", config.codeVerifier)
|
||||
}
|
||||
)
|
||||
return validateRequestTokenResponse(response)
|
||||
}
|
||||
|
||||
suspend fun validateRequestTokenResponse(response: HttpResponse): AuthResult {
|
||||
if (response.status != HttpStatusCode.OK) {
|
||||
try {
|
||||
val errorResponse = response.body<TokenErrorResponse>()
|
||||
return AuthResult.Error(
|
||||
"ERR006",
|
||||
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
|
||||
"${errorResponse.error}: ${errorResponse.errorDescription ?: ""}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Fehler beim JSON-Konvertieren
|
||||
val errorResponse = response.bodyAsText()
|
||||
return AuthResult.Error(
|
||||
"ERR006",
|
||||
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
|
||||
errorResponse
|
||||
)
|
||||
}
|
||||
}
|
||||
val tokenResponse: TokenResponse = try {
|
||||
response.body<TokenResponse>()
|
||||
} catch (e: Exception) {
|
||||
// Fehler beim JSON-Konvertieren
|
||||
return AuthResult.Fatal(
|
||||
"ERR007",
|
||||
"Schwerer Fehler",
|
||||
"Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
)
|
||||
}
|
||||
|
||||
return AuthResult.Success(
|
||||
BearerTokens(
|
||||
tokenResponse.accessToken,
|
||||
tokenResponse.refreshToken
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// suspend fun requestUserInfo(
|
||||
// accessToken: String,
|
||||
// ) {
|
||||
// val config = AuthenticationConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
|
||||
//
|
||||
// val response = httpClient.get(
|
||||
// url = buildUrl {
|
||||
// protocol = URLProtocol.HTTPS
|
||||
// host = config.keycloakServerHostname
|
||||
// path(config.userinfoEndpoint)
|
||||
// },
|
||||
//// formParameters = parameters {
|
||||
//// append("grant_type", "refresh_token")
|
||||
//// append("client_id", config.keycloakClient)
|
||||
//// append("refresh_token", refreshToken)
|
||||
//// }
|
||||
// ) {
|
||||
// headers { append("Authorization", "Bearer $accessToken") }
|
||||
// }
|
||||
//
|
||||
// println("Status: ${response.status}")
|
||||
// println("Body: ${response.bodyAsText()}")
|
||||
//
|
||||
// }
|
||||
|
||||
private suspend fun testServerConnection(config: AuthRun): Boolean {
|
||||
val response: HttpResponse = httpClient.request(
|
||||
url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.certificatesEndpoint
|
||||
}
|
||||
)
|
||||
return response.status.value < 400
|
||||
}
|
||||
|
||||
/*
|
||||
access token:
|
||||
|
||||
{
|
||||
"exp": 1769839803,
|
||||
"iat": 1769839503,
|
||||
"auth_time": 1769839502,
|
||||
"jti": "onrtac:f0ea9436-470d-7d1d-fd9d-b43d2f075c46",
|
||||
"iss": "https://kec.keinekekse.net/realms/app-server-realm",
|
||||
"aud": "account",
|
||||
"sub": "b5b40fa3-468f-443c-8eb0-e220bdd353bc",
|
||||
"typ": "Bearer",
|
||||
"azp": "pkce-book-app",
|
||||
"sid": "n_TeXWIUSV66OkYwoc0D1og9",
|
||||
"acr": "1",
|
||||
"allowed-origins": [
|
||||
"http://127.0.0.1",
|
||||
"https://kas.keinekekse.net"
|
||||
],
|
||||
"realm_access": {
|
||||
"roles": [
|
||||
"offline_access",
|
||||
"default-roles-app-server-realm",
|
||||
"uma_authorization",
|
||||
"book-admin"
|
||||
]
|
||||
},
|
||||
"resource_access": {
|
||||
"account": {
|
||||
"roles": [
|
||||
"manage-account",
|
||||
"manage-account-links",
|
||||
"view-profile"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scope": "openid profile email",
|
||||
"email_verified": true,
|
||||
"name": "Hartmut Bauer",
|
||||
"preferred_username": "hb42",
|
||||
"given_name": "Hartmut",
|
||||
"family_name": "Bauer",
|
||||
"email": "hb242@pm.me"
|
||||
}
|
||||
|
||||
-> realm_access:
|
||||
|
||||
implementation(libs.ktor.serverAuthJwt)
|
||||
|
||||
val decod = JWT().decodeJwt(tokenResponse.accessToken!!)
|
||||
// claims, die nur String enthalten: getClaim("claim-name")
|
||||
// für andere: "{" -> asMap<String, Any> => cast!
|
||||
// "[" -> asList(Class<T>)
|
||||
val roles: List<String> = decod.getClaim("realm_access").asMap()["roles"] as List<String>
|
||||
roles.forEach { println(it) }
|
||||
|
||||
|
||||
refreshTokens {
|
||||
val refreshTokenInfo: TokenInfo = client.submitForm(
|
||||
url = "https://accounts.google.com/o/oauth2/token",
|
||||
formParameters = parameters {
|
||||
append("grant_type", "refresh_token")
|
||||
append("client_id", System.getenv("GOOGLE_CLIENT_ID"))
|
||||
append("refresh_token", oldTokens?.refreshToken ?: "")
|
||||
}
|
||||
) { markAsRefreshTokenRequest() }.body()
|
||||
bearerTokenStorage.add(BearerTokens(refreshTokenInfo.accessToken, oldTokens?.refreshToken!!))
|
||||
bearerTokenStorage.last()
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
sealed interface AuthResult {
|
||||
data class Success(val tokens: BearerTokens) : AuthResult
|
||||
data class Error(val code: String, val message: String, val details: String? = null) :
|
||||
AuthResult
|
||||
|
||||
data class Fatal(val code: String, val message: String, val details: String? = null) :
|
||||
AuthResult
|
||||
}
|
||||
|
||||
internal class KeycloakConfig(
|
||||
val keycloakServerHostname: String,
|
||||
keycloakRealm: String,
|
||||
val keycloakClient: String,
|
||||
) {
|
||||
companion object {
|
||||
const val LOCALHOST = "127.0.0.1"
|
||||
const val CALLBACK_PATH = "/callback"
|
||||
const val SCOPE = "openid"
|
||||
const val PKCE_METHOD = "S256"
|
||||
|
||||
fun generateRandomString(): String {
|
||||
val bytes = ByteArray(64)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||
}
|
||||
|
||||
fun generateCodeChallenge(codeVerifier: String): String {
|
||||
val bytes = codeVerifier.toByteArray(kotlin.text.Charsets.US_ASCII)
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(diges
|
||||
}
|
||||
}
|
||||
|
||||
private val keycloakBaseUrl = "/realms/$keycloakRealm/protocol/openid-connect"
|
||||
|
||||
val authorizationEndpoint = "$keycloakBaseUrl/auth"
|
||||
val tokenEndpoint = "$keycloakBaseUrl/token"
|
||||
val certificatesEndpoint = "$keycloakBaseUrl/certs"
|
||||
val userinfoEndpoint = "$keycloakBaseUrl/userinfo"
|
||||
|
||||
}
|
||||
|
||||
internal class AuthRun(val kc: KeycloakConfig) {
|
||||
val codeVerifier = KeycloakConfig.generateRandomString()
|
||||
val codeChallenge = KeycloakConfig.generateCodeChallenge(codeVerifier)
|
||||
val csrfProtectionState = KeycloakConfig.generateRandomString()
|
||||
|
||||
val resultDeferred = CompletableDeferred<AuthResult>()
|
||||
private var redirectUri_: String? = null
|
||||
val redirectUri: String
|
||||
get() = redirectUri_
|
||||
?: throw IllegalStateException("redirectUri not set, can only be when embedded server is running")
|
||||
|
||||
fun buildRedirectUri(port: Int) {
|
||||
redirectUri_ = "http://${KeycloakConfig.LOCALHOST}:$port${KeycloakConfig.CALLBACK_PATH}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package net.keinekekse.app.auth
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import org.koin.core.annotation.Single
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
|
||||
sealed interface AuthResult {
|
||||
class Success : AuthResult
|
||||
|
||||
// data class Success(val tokens: BearerTokens) : AuthResult
|
||||
data class Error(val code: String, val message: String, val details: String? = null) :
|
||||
AuthResult
|
||||
|
||||
data class Fatal(val code: String, val message: String, val details: String? = null) :
|
||||
AuthResult
|
||||
}
|
||||
|
||||
@Single
|
||||
fun createKeycloakConfig(): KeycloakConfig {
|
||||
|
||||
// TODO muss aus config geholt werden (<--> DI??)
|
||||
|
||||
val keycloakServerHostname = "kec.keinekekse.net"
|
||||
val keycloakRealm = "app-server-realm"
|
||||
val keycloakClient = "pkce-book-app"
|
||||
|
||||
return KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
|
||||
}
|
||||
|
||||
class KeycloakConfig(
|
||||
val keycloakServerHostname: String,
|
||||
keycloakRealm: String,
|
||||
val keycloakClient: String,
|
||||
) {
|
||||
companion object {
|
||||
const val LOCALHOST = "127.0.0.1"
|
||||
const val CALLBACK_PATH = "/callback"
|
||||
const val SCOPE = "openid"
|
||||
const val PKCE_METHOD = "S256"
|
||||
|
||||
// TODO die Hash-Berechnung ist nicht portabel, die beiden fn müssen je Platform implementiert werden
|
||||
fun generateRandomString(): String {
|
||||
val bytes = ByteArray(64)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||
}
|
||||
|
||||
fun generateCodeChallenge(codeVerifier: String): String {
|
||||
val bytes = codeVerifier.toByteArray(Charsets.US_ASCII)
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
|
||||
}
|
||||
}
|
||||
|
||||
private val keycloakBaseUrl = "/realms/$keycloakRealm/protocol/openid-connect"
|
||||
|
||||
val authorizationEndpoint = "$keycloakBaseUrl/auth"
|
||||
val tokenEndpoint = "$keycloakBaseUrl/token"
|
||||
val certificatesEndpoint = "$keycloakBaseUrl/certs"
|
||||
val userinfoEndpoint = "$keycloakBaseUrl/userinfo"
|
||||
|
||||
}
|
||||
|
||||
internal class AuthRun(val kc: KeycloakConfig) {
|
||||
val codeVerifier = KeycloakConfig.generateRandomString()
|
||||
val codeChallenge = KeycloakConfig.generateCodeChallenge(codeVerifier)
|
||||
val csrfProtectionState = KeycloakConfig.generateRandomString()
|
||||
|
||||
val resultDeferred = CompletableDeferred<AuthResult>()
|
||||
private var redirectUri_: String? = null
|
||||
val redirectUri: String
|
||||
get() = redirectUri_
|
||||
?: throw IllegalStateException("redirectUri not set, can only be when embedded server is running")
|
||||
|
||||
fun buildRedirectUri(port: Int) {
|
||||
redirectUri_ = "http://${KeycloakConfig.LOCALHOST}:$port${KeycloakConfig.CALLBACK_PATH}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package net.keinekekse.app.auth
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.cio.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.utils.io.core.Closeable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.keinekekse.app.service.TokenService
|
||||
import org.koin.core.annotation.Single
|
||||
import java.awt.Desktop
|
||||
import java.net.Socket
|
||||
import kotlin.use
|
||||
|
||||
@Single
|
||||
class Authenticator(
|
||||
// @Property("kc_server") keycloakServerHostname: String,
|
||||
// @Property("kc_realm") keycloakRealm: String,
|
||||
// @Property("kc_client") keycloakClient: String,
|
||||
val tokenService: TokenService,
|
||||
private val keycloakConfig: KeycloakConfig,
|
||||
) : Closeable {
|
||||
private val httpClient = HttpClient {
|
||||
install(ContentNegotiation) { json() }
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 10000
|
||||
connectTimeoutMillis = 5000
|
||||
}
|
||||
}
|
||||
|
||||
// private val keycloakConfig =
|
||||
// KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
|
||||
|
||||
override fun close() = httpClient.close()
|
||||
|
||||
suspend fun authenticate(): AuthResult {
|
||||
val config = AuthRun(keycloakConfig)
|
||||
|
||||
if (!testServerConnection(config)) {
|
||||
return AuthResult.Error("ERR001", "Der Anmeldeserver ist nicht erreichbar.", "")
|
||||
}
|
||||
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
||||
try {
|
||||
server = createCallbackServer(config).start(wait = false)
|
||||
val port = server.engine.resolvedConnectors().first().port
|
||||
config.buildRedirectUri(port)
|
||||
if (!waitForServer(port)) {
|
||||
return AuthResult.Fatal(
|
||||
"ERR008",
|
||||
"Schwerer Fehler",
|
||||
"Der interne Webserver konnte nicht gestartet werden. Keine Anmeldung möglich."
|
||||
)
|
||||
}
|
||||
|
||||
openAuthorizationInBrowser(config)
|
||||
|
||||
return config.resultDeferred.await()
|
||||
|
||||
} catch (e: Exception) {
|
||||
return AuthResult.Error(
|
||||
"ERR002",
|
||||
"Bei der Anmeldung ist ein Fehler aufgetreten",
|
||||
e.message ?: "Unbekannter Fehler"
|
||||
)
|
||||
} finally {
|
||||
server?.stop(1000, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestRefreshToken(tokens: BearerTokens?): BearerTokens? {
|
||||
val config = AuthRun(keycloakConfig)
|
||||
|
||||
val response = httpClient.submitForm(
|
||||
url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.tokenEndpoint
|
||||
}.toString(),
|
||||
formParameters = parameters {
|
||||
append("grant_type", "refresh_token")
|
||||
append("client_id", config.kc.keycloakClient)
|
||||
append("refresh_token", tokens?.refreshToken ?: "")
|
||||
}
|
||||
)
|
||||
val result = validateRequestTokenResponse(response)
|
||||
when (result.first) {
|
||||
is AuthResult.Success -> {
|
||||
tokenService.setTokens(result.second)
|
||||
return result.second
|
||||
}
|
||||
|
||||
else -> {
|
||||
tokenService.setTokens(null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPortOpen(port: Int): Boolean =
|
||||
try {
|
||||
// TODO Socket ist nicht portabel (JVM only)
|
||||
Socket(KeycloakConfig.LOCALHOST, port).use { true }
|
||||
} catch (e: Exception) {
|
||||
// Port is closed or unreachable
|
||||
println("${e.javaClass.simpleName}: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
private suspend fun waitForServer(port: Int, timeoutSeconds: Int = 3): Boolean {
|
||||
repeat(timeoutSeconds * 10) {
|
||||
if (isPortOpen(port)) return true
|
||||
delay(100)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun openAuthorizationInBrowser(config: AuthRun) {
|
||||
val authorizationUrl = buildAuthorizationUrl(config)
|
||||
|
||||
// TODO momentan nur für JVM, Rest ist noch zu eruieren
|
||||
if (!Desktop.isDesktopSupported()) {
|
||||
throw IllegalStateException("Desktop API not supported (cannot open browser automatically).")
|
||||
}
|
||||
val desktop = Desktop.getDesktop()
|
||||
if (!desktop.isSupported(Desktop.Action.BROWSE)) {
|
||||
throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).")
|
||||
}
|
||||
|
||||
desktop.browse(authorizationUrl.toURI())
|
||||
}
|
||||
|
||||
private fun buildAuthorizationUrl(config: AuthRun): Url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.authorizationEndpoint
|
||||
parameters.append("response_type", "code")
|
||||
parameters.append("client_id", config.kc.keycloakClient)
|
||||
parameters.append("redirect_uri", config.redirectUri)
|
||||
parameters.append("scope", KeycloakConfig.SCOPE)
|
||||
parameters.append("state", config.csrfProtectionState)
|
||||
parameters.append("code_challenge", config.codeChallenge)
|
||||
parameters.append("code_challenge_method", KeycloakConfig.PKCE_METHOD)
|
||||
}
|
||||
|
||||
private fun createCallbackServer(config: AuthRun) =
|
||||
embeddedServer(CIO, port = 0, host = KeycloakConfig.LOCALHOST) {
|
||||
routing {
|
||||
get(KeycloakConfig.CALLBACK_PATH) {
|
||||
val code = call.request.queryParameters["code"]
|
||||
|
||||
var authResult =
|
||||
validateAuthorizationRequest(call.request, config.csrfProtectionState)
|
||||
|
||||
if (authResult is AuthResult.Success) {
|
||||
authResult = requestTokens(config, code)
|
||||
}
|
||||
|
||||
val (text, status) = createGoodByeMessage(authResult)
|
||||
call.respondText(text, contentType = ContentType.Text.Html, status = status)
|
||||
|
||||
if (!config.resultDeferred.isCompleted)
|
||||
config.resultDeferred.complete(authResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun htmlEscape(value: String): String =
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
|
||||
private fun createGoodByeMessage(authResult: AuthResult): Pair<String, HttpStatusCode> {
|
||||
var text = ""
|
||||
var status = HttpStatusCode.BadRequest
|
||||
when (authResult) {
|
||||
is AuthResult.Success -> {
|
||||
text =
|
||||
"<html><body><h1>Login erfolgreich! Du kannst das Fenster schließen.</h1></body></html>"
|
||||
status = HttpStatusCode.OK
|
||||
}
|
||||
|
||||
is AuthResult.Error -> {
|
||||
text = "<html><body><h1>Fehler bei der Anmeldung.</h1><h2>${
|
||||
htmlEscape(authResult.message)
|
||||
}</h2><h3>${htmlEscape(authResult.details ?: "")}</h3></body></html>"
|
||||
status = HttpStatusCode.BadRequest
|
||||
}
|
||||
|
||||
is AuthResult.Fatal -> {
|
||||
text =
|
||||
"<html><body><h1>Schwerer Fehler.</h1><h2>${htmlEscape(authResult.message)}</h2><h3>${
|
||||
htmlEscape(authResult.details ?: "")
|
||||
}</h3></body></html>"
|
||||
status = HttpStatusCode.InternalServerError
|
||||
}
|
||||
}
|
||||
return Pair(text, status)
|
||||
}
|
||||
|
||||
private fun validateAuthorizationRequest(
|
||||
request: RoutingRequest,
|
||||
csrfProtectionState: String,
|
||||
): AuthResult {
|
||||
/*
|
||||
- state != csrfProt -> Fehler: Kommunikation mit dem Anmeldeserver hat nicht geklappt,
|
||||
könnte auf Malware hindeuten, Abbruch
|
||||
throw Fatal error authenticating user -> exit app
|
||||
- wenn error =! null -> error + error_description anzeigen
|
||||
rc NULL == keine auth -> app muss entscheiden
|
||||
- code == null -> Fehler: Server liefert ungültige Daten,
|
||||
könnte auf Probleme bei der Netzwerkverbindung oder beim Anmeldeserver hindeuten
|
||||
rc NULL -> app
|
||||
*/
|
||||
if (request.queryParameters["error"] != null) {
|
||||
return AuthResult.Error(
|
||||
"ERR004",
|
||||
"Fehler bei der Anmeldung",
|
||||
"${request.queryParameters["error"]}: ${request.queryParameters["error_description"] ?: ""}"
|
||||
)
|
||||
}
|
||||
if (request.queryParameters["state"] != csrfProtectionState) {
|
||||
return AuthResult.Fatal(
|
||||
"ERR005",
|
||||
"Schwerer Fehler",
|
||||
"CSRF-Fehler in der Kommunikation mit dem Anmeldeserver. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
)
|
||||
}
|
||||
return AuthResult.Success()
|
||||
}
|
||||
|
||||
/* Antwort von Keycloak:
|
||||
{
|
||||
"access_token": "eyJhbG ...
|
||||
"expires_in": 300,
|
||||
"refresh_expires_in": 0,
|
||||
"refresh_token": "eyJhb ...
|
||||
"token_type": "Bearer",
|
||||
"id_token": "eyJ ...
|
||||
"not-before-policy": 1769817270,
|
||||
"session_state": "YhlNjIuW-lk1aQwwD9xm4edJ",
|
||||
"scope": "openid profile email"
|
||||
}
|
||||
*/
|
||||
|
||||
@Serializable
|
||||
data class TokenResponse(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("expires_in") val expiresIn: Int,
|
||||
@SerialName("refresh_expires_in") val refreshExpiresIn: Int,
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
@SerialName("token_type") val tokenType: String,
|
||||
@SerialName("id_token") val idToken: String?,
|
||||
@SerialName("not-before-policy") val notBeforePolicy: Int?,
|
||||
@SerialName("session_state") val sessionState: String?,
|
||||
val scope: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TokenErrorResponse(
|
||||
@SerialName("error") val error: String,
|
||||
@SerialName("error_description") val errorDescription: String?,
|
||||
@SerialName("session_state") val sessionState: String?,
|
||||
)
|
||||
|
||||
private suspend fun requestTokens(config: AuthRun, authCode: String?): AuthResult {
|
||||
if (authCode == null) {
|
||||
return AuthResult.Fatal(
|
||||
"ERR003",
|
||||
"Schwerer Fehler",
|
||||
"Der Anmeldeserver lieferte ungültige Daten. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
)
|
||||
}
|
||||
val response: HttpResponse = httpClient.submitForm(
|
||||
url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.tokenEndpoint
|
||||
}.toString(),
|
||||
formParameters = parameters {
|
||||
append("grant_type", "authorization_code")
|
||||
append("client_id", config.kc.keycloakClient)
|
||||
append("code", authCode)
|
||||
append("redirect_uri", config.redirectUri)
|
||||
append("code_verifier", config.codeVerifier)
|
||||
}
|
||||
)
|
||||
val result = validateRequestTokenResponse(response)
|
||||
when (result.first) {
|
||||
is AuthResult.Success -> {
|
||||
tokenService.setTokens(result.second)
|
||||
}
|
||||
|
||||
else -> {
|
||||
tokenService.setTokens(null)
|
||||
}
|
||||
}
|
||||
return result.first
|
||||
}
|
||||
|
||||
suspend fun validateRequestTokenResponse(response: HttpResponse): Pair<AuthResult, BearerTokens?> {
|
||||
if (response.status != HttpStatusCode.OK) {
|
||||
try {
|
||||
val errorResponse = response.body<TokenErrorResponse>()
|
||||
return Pair(
|
||||
AuthResult.Error(
|
||||
"ERR006",
|
||||
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
|
||||
"${errorResponse.error}: ${errorResponse.errorDescription ?: ""}"
|
||||
), null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Fehler beim JSON-Konvertieren
|
||||
val errorResponse = response.bodyAsText()
|
||||
return Pair(
|
||||
AuthResult.Error(
|
||||
"ERR006",
|
||||
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
|
||||
errorResponse
|
||||
), null
|
||||
)
|
||||
}
|
||||
}
|
||||
val tokenResponse: TokenResponse = try {
|
||||
response.body<TokenResponse>()
|
||||
} catch (e: Exception) {
|
||||
// Fehler beim JSON-Konvertieren
|
||||
return Pair(
|
||||
AuthResult.Fatal(
|
||||
"ERR007",
|
||||
"Schwerer Fehler",
|
||||
"Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
), null
|
||||
)
|
||||
}
|
||||
|
||||
return Pair(
|
||||
AuthResult.Success(),
|
||||
BearerTokens(
|
||||
tokenResponse.accessToken,
|
||||
tokenResponse.refreshToken
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// suspend fun requestUserInfo(
|
||||
// accessToken: String,
|
||||
// ) {
|
||||
// val config = AuthenticationConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
|
||||
//
|
||||
// val response = httpClient.get(
|
||||
// url = buildUrl {
|
||||
// protocol = URLProtocol.HTTPS
|
||||
// host = config.keycloakServerHostname
|
||||
// path(config.userinfoEndpoint)
|
||||
// },
|
||||
//// formParameters = parameters {
|
||||
//// append("grant_type", "refresh_token")
|
||||
//// append("client_id", config.keycloakClient)
|
||||
//// append("refresh_token", refreshToken)
|
||||
//// }
|
||||
// ) {
|
||||
// headers { append("Authorization", "Bearer $accessToken") }
|
||||
// }
|
||||
//
|
||||
// println("Status: ${response.status}")
|
||||
// println("Body: ${response.bodyAsText()}")
|
||||
//
|
||||
// }
|
||||
|
||||
private suspend fun testServerConnection(config: AuthRun): Boolean {
|
||||
val response: HttpResponse = httpClient.request(
|
||||
url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.certificatesEndpoint
|
||||
}
|
||||
)
|
||||
return response.status.value < 400
|
||||
}
|
||||
|
||||
/*
|
||||
access token:
|
||||
|
||||
{
|
||||
"exp": 1769839803,
|
||||
"iat": 1769839503,
|
||||
"auth_time": 1769839502,
|
||||
"jti": "onrtac:f0ea9436-470d-7d1d-fd9d-b43d2f075c46",
|
||||
"iss": "https://kec.keinekekse.net/realms/app-server-realm",
|
||||
"aud": "account",
|
||||
"sub": "b5b40fa3-468f-443c-8eb0-e220bdd353bc",
|
||||
"typ": "Bearer",
|
||||
"azp": "pkce-book-app",
|
||||
"sid": "n_TeXWIUSV66OkYwoc0D1og9",
|
||||
"acr": "1",
|
||||
"allowed-origins": [
|
||||
"http://127.0.0.1",
|
||||
"https://kas.keinekekse.net"
|
||||
],
|
||||
"realm_access": {
|
||||
"roles": [
|
||||
"offline_access",
|
||||
"default-roles-app-server-realm",
|
||||
"uma_authorization",
|
||||
"book-admin"
|
||||
]
|
||||
},
|
||||
"resource_access": {
|
||||
"account": {
|
||||
"roles": [
|
||||
"manage-account",
|
||||
"manage-account-links",
|
||||
"view-profile"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scope": "openid profile email",
|
||||
"email_verified": true,
|
||||
"name": "Hartmut Bauer",
|
||||
"preferred_username": "hb42",
|
||||
"given_name": "Hartmut",
|
||||
"family_name": "Bauer",
|
||||
"email": "hb242@pm.me"
|
||||
}
|
||||
|
||||
-> realm_access:
|
||||
|
||||
implementation(libs.ktor.serverAuthJwt)
|
||||
|
||||
val decod = JWT().decodeJwt(tokenResponse.accessToken!!)
|
||||
// claims, die nur String enthalten: getClaim("claim-name")
|
||||
// für andere: "{" -> asMap<String, Any> => cast!
|
||||
// "[" -> asList(Class<T>)
|
||||
val roles: List<String> = decod.getClaim("realm_access").asMap()["roles"] as List<String>
|
||||
roles.forEach { println(it) }
|
||||
|
||||
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package net.keinekekse.app
|
||||
|
||||
//import org.koin.plugin.module.dsl.startKoin
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.keinekekse.app.auth.AuthResult
|
||||
import net.keinekekse.app.auth.Authenticator
|
||||
import net.keinekekse.app.books.di.KoinApp
|
||||
import net.keinekekse.app.ui.ProgressIndicator
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.plugin.module.dsl.startKoin
|
||||
|
||||
//@PropertyValue("kc_server")
|
||||
//var keycloakServer: String = ""
|
||||
//
|
||||
//@PropertyValue("kc_realm")
|
||||
//var keycloakRealm: String = ""
|
||||
//
|
||||
//@PropertyValue("kc_client")
|
||||
//var keycloakClient: String = ""
|
||||
|
||||
fun main() = application {
|
||||
|
||||
startKoin<KoinApp>()
|
||||
|
||||
// keycloakServer = "kec.keinekekse.net"
|
||||
// keycloakRealm = "app-server-realm"
|
||||
// keycloakClient = "pkce-book-app"
|
||||
|
||||
// val inj = koinInject<String>(named("AppString"))
|
||||
|
||||
val authResultState = remember { mutableStateOf<AuthResult?>(null) }
|
||||
|
||||
val authenticator: Authenticator = koinInject()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
authResultState.value = withContext(Dispatchers.IO) {
|
||||
// TODO - (~) Verständnis für App-Aufbau und DI
|
||||
// - (x) das object muss global angelegt werden -> DI singleton
|
||||
// - (x) globale Speicherung der Tokens
|
||||
// - Tokens persistieren? Multiplatform! (evtl. library)
|
||||
// - (x) globaler httpClient mit Konfig für Tokens -> DI
|
||||
// - (x) Error-Handling: 401 -> refresh -> 401 -> reauth [implizit]
|
||||
// Authenticator(
|
||||
// "kec.keinekekse.net",
|
||||
// "app-server-realm",
|
||||
// "pkce-book-app",
|
||||
// ).use {
|
||||
|
||||
val result = authenticator.authenticate()
|
||||
|
||||
when (result) {
|
||||
is AuthResult.Success -> {
|
||||
// println("Access Token: ${result.tokens?.accessToken}")
|
||||
// println("Refresh Token: ${result.tokens?.refreshToken}")
|
||||
|
||||
// delay(3000)
|
||||
/*
|
||||
// klappt, gibt aber nicht mehr her, als ohnehin im Token steht
|
||||
it.requestUserInfo(result.tokens!!.accessToken)
|
||||
*/
|
||||
/*
|
||||
// funktioniert -> jetzt fehlt die DI-Mimik für die compose app
|
||||
when (val res = it.requestRefreshToken(result.tokens)) {
|
||||
is AuthResult.Success -> {
|
||||
println("Access Token: ${res.tokens?.accessToken}")
|
||||
println("Refresh Token: ${res.tokens?.refreshToken}")
|
||||
}
|
||||
|
||||
is AuthResult.Error -> {
|
||||
println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}")
|
||||
}
|
||||
|
||||
is AuthResult.Fatal -> {
|
||||
println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}")
|
||||
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
result
|
||||
// }
|
||||
}
|
||||
}
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "app",
|
||||
) {
|
||||
when (val result = authResultState.value) {
|
||||
null -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text("Warten auf Anmeldung...")
|
||||
|
||||
// Icon(
|
||||
// imageVector = Icons.Default.HourglassTop,
|
||||
// contentDescription = "Home",
|
||||
// modifier = Modifier.padding(top = 16.dp).size(32.dp)
|
||||
// )
|
||||
ProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
is AuthResult.Error -> {
|
||||
// TODO show an error UI (and maybe a retry button)
|
||||
Text("Fehler bei der Anmeldung: ${result.message} (${result.code})")
|
||||
Text("Fehlerbeschreibung: ${result.details ?: ""}")
|
||||
// App()
|
||||
}
|
||||
|
||||
is AuthResult.Fatal -> {
|
||||
Text("Fataler Fehler bei der Anmeldung: ${result.message} (${result.code})")
|
||||
Text("Fehlerbeschreibung: ${result.details ?: ""}")
|
||||
}
|
||||
|
||||
is AuthResult.Success -> {
|
||||
val test = koinInject<TestAuth>()
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
println("requesting '/'")
|
||||
test.request("/")
|
||||
}
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//
|
||||
//@Composable
|
||||
//fun ProgressIndicator(animationSize: Int = 48, paddingTop: Int = 16, strokeWidth: Int = 5) {
|
||||
// val primaryColor = MaterialTheme.colorScheme.primary
|
||||
// val backgroundColor = MaterialTheme.colorScheme.background
|
||||
// val strokewidth = strokeWidth.dp
|
||||
// CircularProgressIndicator(
|
||||
// modifier =
|
||||
// Modifier.padding(top = paddingTop.dp).size(animationSize.dp).drawBehind {
|
||||
// drawCircle(
|
||||
// primaryColor,
|
||||
// radius = size.width / 2 - strokewidth.toPx() / 2,
|
||||
// style = Stroke(strokewidth.toPx()),
|
||||
//
|
||||
// )
|
||||
// },
|
||||
// color = backgroundColor,
|
||||
// strokeWidth = strokewidth
|
||||
// )
|
||||
//}
|
||||
@@ -0,0 +1,22 @@
|
||||
package net.keinekekse.app.service
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.auth.*
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import net.keinekekse.app.auth.Authenticator
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
fun createHttpClient(authenticator: Authenticator) = HttpClient {
|
||||
install(Auth) {
|
||||
bearer {
|
||||
loadTokens {
|
||||
authenticator.tokenService.getTokens()
|
||||
}
|
||||
refreshTokens {
|
||||
authenticator.requestRefreshToken(oldTokens)
|
||||
authenticator.tokenService.getTokens()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package net.keinekekse.app.service
|
||||
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class TokenService {
|
||||
private var tokens: BearerTokens? = null
|
||||
|
||||
// TODO persist tokens
|
||||
|
||||
fun getTokens(): BearerTokens? = tokens
|
||||
fun setTokens(tokens: BearerTokens? = null) {
|
||||
this.tokens = tokens
|
||||
println("set tokens")
|
||||
println("Access Token: ${tokens?.accessToken}")
|
||||
println("Refresh Token: ${tokens?.refreshToken}")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package net.keinekekse.app
|
||||
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.ComposeViewport
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
ComposeViewport {
|
||||
App()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>app</title>
|
||||
<link href="styles.css" rel="stylesheet" type="text/css">
|
||||
</head>
|
||||
<body style="text-align: center; align-content: center">
|
||||
<svg height="40" role="presentation" viewBox="0 0 50 50" width="40"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="25" cy="25" fill="none" r="20" stroke="#ccc" stroke-width="4"/>
|
||||
<circle cx="25" cy="25" fill="none" r="20" stroke="#333" stroke-dasharray="90 125" stroke-linecap="round"
|
||||
stroke-width="4">
|
||||
<animateTransform attributeName="transform" dur="1s" from="0 25 25" repeatCount="indefinite" to="360 25 25"
|
||||
type="rotate"/>
|
||||
</circle>
|
||||
</svg>
|
||||
<script src="composeApp.js" type="application/javascript"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
plugins {
|
||||
// this is necessary to avoid the plugins to be loaded multiple times
|
||||
// in each subproject's classloader
|
||||
alias(libs.plugins.composeHotReload) apply false
|
||||
alias(libs.plugins.composeMultiplatform) apply false
|
||||
alias(libs.plugins.composeCompiler) apply false
|
||||
alias(libs.plugins.kotlinJvm) apply false
|
||||
alias(libs.plugins.kotlinMultiplatform) apply false
|
||||
alias(libs.plugins.ktor) apply false
|
||||
alias(libs.plugins.kotlinSerialization) apply false
|
||||
alias(libs.plugins.koin.compiler) apply false
|
||||
}
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
|
||||
rsync -rti --delete --stats server/build/install/server/* /Users/hb/mnt/nas/Server/vol/kas/app
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#Kotlin
|
||||
kotlin.code.style=official
|
||||
kotlin.daemon.jvmargs=-Xmx3072M
|
||||
org.jetbrains.compose.experimental.macos.enabled=true
|
||||
#Gradle
|
||||
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
||||
org.gradle.configuration-cache=true
|
||||
org.gradle.caching=true
|
||||
@@ -0,0 +1,98 @@
|
||||
[versions]
|
||||
serverVersion = "0.1.0-beta.1"
|
||||
# z.Zt. ist nur major > 0.minor.patch möglich für Apple-Targets
|
||||
# -> https://youtrack.jetbrains.com/issue/CMP-2360
|
||||
# nach compose multiplatform 1.11 nochmal anschauen
|
||||
composeAppVersion = "1.0.0"
|
||||
|
||||
androidx-lifecycle = "2.9.6"
|
||||
composeHotReload = "1.0.0"
|
||||
composeMultiplatform = "1.10.0"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.3.0"
|
||||
kotlin-wrappers-css = "2025.6.4"
|
||||
kotlinx-coroutines = "1.10.2"
|
||||
kotlinx-html = "0.12.0"
|
||||
koin-bom = "4.2.0-RC1"
|
||||
koin-plugin = "0.3.0"
|
||||
ktor = "3.4.0"
|
||||
logback = "1.5.26"
|
||||
material3 = "1.10.0-alpha05"
|
||||
uiDesktop = "1.7.0"
|
||||
|
||||
[libraries]
|
||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
# -- compose bundle
|
||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
|
||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
||||
compose-materialIcons = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "uiDesktop" }
|
||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
|
||||
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
|
||||
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
|
||||
# --
|
||||
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-html = { module = "org.jetbrains.kotlinx:kotlinx-html", version.ref = "kotlinx-html" }
|
||||
kotlin-wrappersCss = { module = "org.jetbrains.kotlin-wrappers:kotlin-css-jvm", version.ref = "kotlin-wrappers-css" }
|
||||
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||
ktor-serverCore = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
||||
ktor-serverNetty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
|
||||
ktor-serverCio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
|
||||
ktor-serverAuth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" }
|
||||
ktor-serverAuthJwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref = "ktor" }
|
||||
ktor-serverAuthApiKey = { module = "io.ktor:ktor-server-auth-api-key", version.ref = "ktor" }
|
||||
ktor-serializationJson = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
ktor-callLogging = { module = "io.ktor:ktor-call-logging", version.ref = "ktor" }
|
||||
ktor-serverHtmlBuilder = { module = "io.ktor:ktor-server-html-builder", version.ref = "ktor" }
|
||||
ktor-serverHtmx = { module = "io.ktor:ktor-server-htmx", version.ref = "ktor" }
|
||||
ktor-htmxHtml = { module = "io.ktor:ktor-htmx-html", version.ref = "ktor" }
|
||||
ktor-serverContentNegotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
|
||||
ktor-serverTestHost = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor" }
|
||||
ktor-client = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-clientAuth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
|
||||
ktor-clientLogging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
||||
ktor-clientContentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-clientJava = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
|
||||
ktor-clientOkHttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||
ktor-clientDarwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
||||
ktor-clientWinHttp = { module = "io.ktor:ktor-client-winhttp", version.ref = "ktor" }
|
||||
ktor-clientCurl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" }
|
||||
ktor-clientJs = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
|
||||
ktor-clientCio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||
androidx-ui-desktop = { group = "androidx.compose.ui", name = "ui-desktop", version.ref = "uiDesktop" }
|
||||
# -- Koin
|
||||
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
|
||||
koin-core = { module = "io.insert-koin:koin-core" }
|
||||
koin-annotations = { module = "io.insert-koin:koin-annotations" }
|
||||
koin-android = { module = "io.insert-koin:koin-android" }
|
||||
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose" }
|
||||
koin-compose = { module = "io.insert-koin:koin-compose" }
|
||||
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
|
||||
koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-viewmodel-navigation" }
|
||||
# z.Zt. alpha
|
||||
#koin-compose-viewmodel-navigation3 = { module = "io.insert-koin:koin-compose-viewmodel-navigation3" }
|
||||
koin-ktor = { module = "io.insert-koin:koin-ktor" }
|
||||
koin-test = { module = "io.insert-koin:koin-test" }
|
||||
# --
|
||||
|
||||
[bundles]
|
||||
kotlin-compose = [
|
||||
"compose-ui", "compose-runtime", "compose-foundation",
|
||||
"compose-components-resources", "compose-uiToolingPreview", "compose-materialIcons",
|
||||
"compose-material3",
|
||||
"androidx-lifecycle-runtimeCompose", "androidx-lifecycle-viewmodelCompose"
|
||||
]
|
||||
|
||||
[plugins]
|
||||
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }
|
||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
||||
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
|
||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" }
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,8 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@js-joda/core@3.2.0":
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
|
||||
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinJvm)
|
||||
alias(libs.plugins.ktor)
|
||||
application
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
alias(masterLibs.plugins.versionPlugin)
|
||||
}
|
||||
|
||||
group = "net.keinekekse.app"
|
||||
version = libs.versions.serverVersion.get()
|
||||
description = "ktor Application-Server"
|
||||
extra["displayname"] = "kAS"
|
||||
extra["copyright"] = "Copyright (c) 2026 Hartmut Bauer"
|
||||
|
||||
application {
|
||||
mainClass.set("net.keinekekse.app.ApplicationKt")
|
||||
|
||||
val isDevelopment: Boolean = project.ext.has("development")
|
||||
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(masterLibs.utilLibrary)
|
||||
// implementation(projects.shared) // err wg. doppelter jar ??
|
||||
implementation(libs.logback)
|
||||
implementation(libs.ktor.serverCore)
|
||||
implementation(libs.ktor.serverNetty)
|
||||
implementation(libs.ktor.serverAuth)
|
||||
implementation(libs.ktor.serverAuthApiKey)
|
||||
implementation(libs.ktor.serverAuthJwt)
|
||||
implementation(libs.ktor.serverContentNegotiation)
|
||||
implementation(libs.ktor.serializationJson)
|
||||
|
||||
testImplementation(libs.ktor.serverTestHost)
|
||||
testImplementation(libs.kotlin.testJunit)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(masterLibs.versions.javaVersion.get().toInt())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#auto generated
|
||||
#Tue Feb 03 00:47:38 CET 2026
|
||||
build.number=19
|
||||
@@ -0,0 +1,19 @@
|
||||
package net.keinekekse.app
|
||||
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.netty.*
|
||||
import net.keinekekse.app.server.VERSION
|
||||
import net.keinekekse.app.server.config.configureSecurity
|
||||
|
||||
fun main() {
|
||||
// embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
|
||||
println("**\n** Starting ${VERSION.displayName} - ${VERSION.description} ${VERSION.fullVersion}\n**")
|
||||
embeddedServer(Netty, port = 7770, host = "0.0.0.0", module = Application::module)
|
||||
.start(wait = true)
|
||||
}
|
||||
|
||||
fun Application.module() {
|
||||
configureSecurity()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package net.keinekekse.app.server
|
||||
|
||||
import net.keinekekse.version.Version
|
||||
|
||||
object VERSION: Version {
|
||||
override val version = "0.1.0-beta.1+build.19"
|
||||
override val fullVersion = "0.1.0-beta.1+build.19"
|
||||
override val major = 0
|
||||
override val minor = 1
|
||||
override val patch = 0
|
||||
override val preRelease = "beta.1"
|
||||
override val buildMetadata = "build.19"
|
||||
override val buildDate = "2026-02-02T23:47:38.629893Z"
|
||||
override val displayName = "kAS"
|
||||
override val copyright = "Copyright (c) 2026 Hartmut Bauer"
|
||||
override val name = "server"
|
||||
override val description = "ktor Application-Server"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package net.keinekekse.app.server.config
|
||||
|
||||
import io.ktor.server.auth.jwt.*
|
||||
import java.security.Principal
|
||||
|
||||
class AppPrincipal(credential: JWTCredential) : Principal {
|
||||
val userId: String = credential.getClaim("preferred_username", String::class) ?: ""
|
||||
val vorname: String = credential.getClaim("given_name", String::class) ?: ""
|
||||
val nachname: String = credential.getClaim("family_name", String::class) ?: ""
|
||||
val email: String = credential.getClaim("email", String::class) ?: ""
|
||||
val roles: List<String>
|
||||
|
||||
init {
|
||||
val realmAccess: Map<*, *>? = credential.getClaim("realm_access", Map::class)
|
||||
val list = realmAccess?.get("roles")
|
||||
roles = if (list is List<*>) list.map { it as String } else emptyList()
|
||||
}
|
||||
|
||||
override fun getName(): String {
|
||||
return "$userId ($vorname $nachname) <$email>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package net.keinekekse.app.server.config
|
||||
|
||||
import com.auth0.jwk.UrlJwkProvider
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.auth.jwt.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import net.keinekekse.app.server.VERSION
|
||||
import java.net.URI
|
||||
|
||||
fun Application.configureSecurity() {
|
||||
|
||||
// TODO die Werte müssen aus der Config kommen
|
||||
|
||||
// Kommunikation funktioniert nur im internen Containernetzwerk,
|
||||
// d.h. Container- bzw. Pod-Name + interner Port
|
||||
val keycloakInternal = "http://kec:8080/realms"
|
||||
val keycloak = "https://kec.keinekekse.net/realms"
|
||||
val realm = "app-server-realm"
|
||||
val clientId = "pkce-book-app"
|
||||
val certUrl = "$keycloakInternal/$realm/protocol/openid-connect/certs"
|
||||
val issuer = "$keycloak/$realm"
|
||||
authentication {
|
||||
jwt("auth-KC") {
|
||||
verifier(
|
||||
UrlJwkProvider(URI(certUrl).toURL()), issuer
|
||||
) {
|
||||
withAudience("account")
|
||||
withClaim("azp", clientId)
|
||||
}
|
||||
challenge { _, _ ->
|
||||
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
|
||||
}
|
||||
validate { credential ->
|
||||
AppPrincipal(credential)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
routing {
|
||||
authenticate("auth-KC") {
|
||||
get("/") {
|
||||
val principal = call.principal<AppPrincipal>()
|
||||
call.respondText("Ktor ${VERSION.displayName} ${VERSION.fullVersion} - principal=${principal?.name}")
|
||||
}
|
||||
|
||||
get("/test") {
|
||||
val principal = call.principal<AppPrincipal>()
|
||||
call.respondText("user: ${principal?.userId}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<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="trace">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
<logger name="org.eclipse.jetty" level="INFO"/>
|
||||
<logger name="io.netty" level="INFO"/>
|
||||
</configuration>
|
||||
@@ -0,0 +1,43 @@
|
||||
rootProject.name = "app"
|
||||
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven("https://nexus.keinekekse.net/repository/maven-public/")
|
||||
google {
|
||||
mavenContent {
|
||||
includeGroupAndSubgroups("androidx")
|
||||
includeGroupAndSubgroups("com.android")
|
||||
includeGroupAndSubgroups("com.google")
|
||||
}
|
||||
}
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
maven("https://nexus.keinekekse.net/repository/maven-public/")
|
||||
google {
|
||||
mavenContent {
|
||||
includeGroupAndSubgroups("androidx")
|
||||
includeGroupAndSubgroups("com.android")
|
||||
includeGroupAndSubgroups("com.google")
|
||||
}
|
||||
}
|
||||
}
|
||||
versionCatalogs {
|
||||
create("masterLibs") {
|
||||
from("net.keinekekse:version-catalog:128")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
include(":books")
|
||||
include(":server")
|
||||
include(":shared")
|
||||
@@ -0,0 +1,38 @@
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeCompiler) // sonst fkt. Composable-Import nicht
|
||||
alias(libs.plugins.koin.compiler)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
@OptIn(ExperimentalWasmDsl::class)
|
||||
wasmJs {
|
||||
// nodejs()
|
||||
browser()
|
||||
}
|
||||
macosArm64()
|
||||
// In einem Compose-Projekt gibt das Ärger, weil Compose nicht mit
|
||||
// linux + mingw kompatibel ist. Sonst müsste alle UI über die
|
||||
// restlichen Plattformen verstreut werden.
|
||||
// linuxX64()
|
||||
// mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.bundles.kotlin.compose)
|
||||
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
}
|
||||
wasmJsMain.dependencies {
|
||||
}
|
||||
macosArm64Main.dependencies {
|
||||
}
|
||||
|
||||
}
|
||||
jvmToolchain(masterLibs.versions.javaVersion.get().toInt())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package net.keinekekse.app
|
||||
|
||||
const val SERVER_PORT = 7770
|
||||
@@ -0,0 +1,9 @@
|
||||
package net.keinekekse.app
|
||||
|
||||
class Greeting {
|
||||
private val platform = getPlatform()
|
||||
|
||||
fun greet(): String {
|
||||
return "Hello, ${platform.name}!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.keinekekse.app
|
||||
|
||||
interface Platform {
|
||||
val name: String
|
||||
}
|
||||
|
||||
expect fun getPlatform(): Platform
|
||||
@@ -0,0 +1,31 @@
|
||||
package net.keinekekse.app.ui
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ProgressIndicator(animationSize: Int = 48, paddingTop: Int = 16, strokeWidth: Int = 5) {
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
val strokewidth = strokeWidth.dp
|
||||
CircularProgressIndicator(
|
||||
modifier =
|
||||
Modifier.padding(top = paddingTop.dp).size(animationSize.dp).drawBehind {
|
||||
drawCircle(
|
||||
primaryColor,
|
||||
radius = size.width / 2 - strokewidth.toPx() / 2,
|
||||
style = Stroke(strokewidth.toPx()),
|
||||
|
||||
)
|
||||
},
|
||||
color = backgroundColor,
|
||||
strokeWidth = strokewidth
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.keinekekse.app
|
||||
|
||||
class JVMPlatform : Platform {
|
||||
override val name: String = "Java ${System.getProperty("java.version")}"
|
||||
}
|
||||
|
||||
actual fun getPlatform(): Platform = JVMPlatform()
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.keinekekse.app
|
||||
|
||||
class MacOsPlatform : Platform {
|
||||
override val name: String = "macOS"
|
||||
}
|
||||
|
||||
actual fun getPlatform(): Platform = MacOsPlatform()
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.keinekekse.app
|
||||
|
||||
class WasmPlatform : Platform {
|
||||
override val name: String = "Web with Kotlin/Wasm"
|
||||
}
|
||||
|
||||
actual fun getPlatform(): Platform = WasmPlatform()
|
||||
Reference in new issue
Block a user