From 2a1487037b0e621b3e26d03828beb656341bfafa Mon Sep 17 00:00:00 2001 From: jay-tux Date: Tue, 4 Aug 2026 12:12:25 +0200 Subject: [PATCH] Initial version (Server, Compose clients work) --- .gitignore | 49 ++ build.gradle.kts | 14 + clients/build.gradle.kts | 102 +++ .../com/jaytux/phoebench/clients/App.kt | 15 + .../jaytux/phoebench/clients/AuthProvider.kt | 99 +++ .../com/jaytux/phoebench/clients/Client.kt | 112 +++ .../jaytux/phoebench/clients/PlatformAPI.kt | 23 + .../jaytux/phoebench/clients/SnackProvider.kt | 51 ++ .../com/jaytux/phoebench/clients/Util.kt | 85 +++ .../jaytux/phoebench/clients/data/HomeVM.kt | 161 ++++ .../phoebench/clients/data/IHomeRepo.kt | 69 ++ .../phoebench/clients/data/IProjectRepo.kt | 77 ++ .../phoebench/clients/data/MutableStateSet.kt | 59 ++ .../phoebench/clients/data/ProjectVM.kt | 154 ++++ .../phoebench/clients/theme/Constants.kt | 5 + .../jaytux/phoebench/clients/ui/HomeView.kt | 694 ++++++++++++++++++ .../clients/ui/NoFeedbackIndication.kt | 18 + .../phoebench/clients/ui/ProjectView.kt | 529 +++++++++++++ .../jaytux/phoebench/clients/ui/Widgets.kt | 33 + .../com/jaytux/phoebench/clients/Main.kt | 10 + .../phoebench/clients/PlatformAPI.desktop.kt | 58 ++ .../com/jaytux/phoebench/clients/Main.kt | 12 + .../phoebench/clients/PlatformAPI.wasmJs.kt | 49 ++ clients/src/wasmJsMain/resources/index.html | 12 + clients/src/wasmJsMain/resources/styles.css | 7 + common/build.gradle.kts | 81 ++ common/partialize.main.kts | 95 +++ .../com/jaytux/phoebench/common/ApiRoute.kt | 239 ++++++ .../com/jaytux/phoebench/common/Auth.kt | 5 + .../com/jaytux/phoebench/common/Either.kt | 27 + .../com/jaytux/phoebench/common/IClient.kt | 10 + .../com/jaytux/phoebench/common/Requests.kt | 39 + .../com/jaytux/phoebench/common/Responses.kt | 61 ++ .../com/jaytux/phoebench/common/Routes.kt | 44 ++ .../com/jaytux/phoebench/common/TimeUnit.kt | 15 + gradle.properties | 2 + gradle/libs.versions.toml | 91 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 234 ++++++ gradlew.bat | 89 +++ kotlin-js-store/wasm/yarn.lock | 13 + server/build.gradle.kts | 75 ++ .../com/jaytux/phoebench/server/Auth.kt | 41 ++ .../com/jaytux/phoebench/server/DotEnv.kt | 17 + .../com/jaytux/phoebench/server/Main.kt | 169 +++++ .../com/jaytux/phoebench/server/Util.kt | 24 + .../jaytux/phoebench/server/db/ArrayColumn.kt | 15 + .../com/jaytux/phoebench/server/db/DB.kt | 33 + .../jaytux/phoebench/server/db/Entities.kt | 71 ++ .../com/jaytux/phoebench/server/db/Tables.kt | 50 ++ .../phoebench/server/handlers/AuthHandler.kt | 202 +++++ .../phoebench/server/handlers/Bcrypt.kt | 10 + .../phoebench/server/handlers/Bridge.kt | 143 ++++ .../server/handlers/ProjectHandler.kt | 170 +++++ .../phoebench/server/handlers/RouteError.kt | 78 ++ server/src/main/resources/application.conf | 20 + .../main/resources/simplelogger.properties | 1 + settings.gradle.kts | 34 + 59 files changed, 4701 insertions(+) create mode 100644 .gitignore create mode 100644 build.gradle.kts create mode 100644 clients/build.gradle.kts create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/App.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/AuthProvider.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/Client.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/SnackProvider.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/Util.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IHomeRepo.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IProjectRepo.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/MutableStateSet.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/theme/Constants.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/NoFeedbackIndication.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt create mode 100644 clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/Widgets.kt create mode 100644 clients/src/desktopMain/kotlin/com/jaytux/phoebench/clients/Main.kt create mode 100644 clients/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt create mode 100644 clients/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/Main.kt create mode 100644 clients/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt create mode 100644 clients/src/wasmJsMain/resources/index.html create mode 100644 clients/src/wasmJsMain/resources/styles.css create mode 100644 common/build.gradle.kts create mode 100755 common/partialize.main.kts create mode 100644 common/src/commonMain/kotlin/com/jaytux/phoebench/common/ApiRoute.kt create mode 100644 common/src/commonMain/kotlin/com/jaytux/phoebench/common/Auth.kt create mode 100644 common/src/commonMain/kotlin/com/jaytux/phoebench/common/Either.kt create mode 100644 common/src/commonMain/kotlin/com/jaytux/phoebench/common/IClient.kt create mode 100644 common/src/commonMain/kotlin/com/jaytux/phoebench/common/Requests.kt create mode 100644 common/src/commonMain/kotlin/com/jaytux/phoebench/common/Responses.kt create mode 100644 common/src/commonMain/kotlin/com/jaytux/phoebench/common/Routes.kt create mode 100644 common/src/commonMain/kotlin/com/jaytux/phoebench/common/TimeUnit.kt create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 kotlin-js-store/wasm/yarn.lock create mode 100644 server/build.gradle.kts create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/Auth.kt create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/DotEnv.kt create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/Main.kt create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/Util.kt create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/db/ArrayColumn.kt create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/db/DB.kt create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/db/Entities.kt create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/db/Tables.kt create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/handlers/AuthHandler.kt create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/handlers/Bcrypt.kt create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/handlers/Bridge.kt create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/handlers/ProjectHandler.kt create mode 100644 server/src/main/kotlin/com/jaytux/phoebench/server/handlers/RouteError.kt create mode 100644 server/src/main/resources/application.conf create mode 100644 server/src/main/resources/simplelogger.properties create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1775d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Kotlin ### +.kotlin + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +*.db +.env +.idea/ \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..8c51a0a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.serialization) apply false + alias(libs.plugins.jvm) apply false + alias(libs.plugins.ktor) apply false +} + +repositories { + mavenCentral() +} + +version = "0.1.0-dev" \ No newline at end of file diff --git a/clients/build.gradle.kts b/clients/build.gradle.kts new file mode 100644 index 0000000..9a161b7 --- /dev/null +++ b/clients/build.gradle.kts @@ -0,0 +1,102 @@ +//import org.gradle.kotlin.dsl.implementation +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.serialization) +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xcontext-parameters") + optIn.add("kotlin.uuid.ExperimentalUuidApi") + } + + jvm("desktop") + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser { + val rootDirPath = project.rootDir.path + val projectDirPath = project.projectDir.path + commonWebpackConfig { + outputFileName = "composeApp.js" + devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { + static(rootDirPath) + static(projectDirPath) + } + } + } + binaries.executable() + } + + applyDefaultHierarchyTemplate() + + sourceSets { + val desktopMain by getting + + val nonWeb by creating { + dependsOn(commonMain.get()) + + dependencies { + } + } + + desktopMain.dependsOn(nonWeb) + + commonMain.dependencies { + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.components.resources) + implementation(libs.compose.material3) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.client.auth) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.auth) + implementation(libs.ktor.client.logging) + implementation(project(":common")) + implementation(kotlin("reflect")) + implementation(libs.compose.backhandler) + implementation(libs.kotlinx.atomic) + implementation(libs.lucide) + implementation(libs.koala) + implementation(libs.kolor) + } + desktopMain.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.ktor.client.okhttp) + implementation(libs.slf4j.simple) + implementation(libs.java.keystore) + } + wasmJsMain.dependencies { + implementation(libs.ktor.client.js) + } + } +} + +compose.desktop { + application { + mainClass = "com.jaytux.phoebench.clients.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "com.jaytux.phoebench.clients" + packageVersion = rootProject.version.toString().split('-')[0] + } + } +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/App.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/App.kt new file mode 100644 index 0000000..33442df --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/App.kt @@ -0,0 +1,15 @@ +package com.jaytux.phoebench.clients + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.jaytux.phoebench.clients.ui.RootView + +@Composable +fun App() = MaterialTheme(darkColorScheme()) { + val auth = remember { AuthProvider.construct() } + val client = remember { Client.construct(auth) } + val snacks = remember { SnackProvider.construct() } + RootView(auth, client) +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/AuthProvider.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/AuthProvider.kt new file mode 100644 index 0000000..3101ba5 --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/AuthProvider.kt @@ -0,0 +1,99 @@ +package com.jaytux.phoebench.clients + +import androidx.compose.runtime.mutableStateOf +import com.jaytux.phoebench.common.TokenResponse +import io.ktor.client.plugins.auth.providers.BearerTokens +import kotlinx.atomicfu.locks.ReentrantLock +import kotlinx.atomicfu.locks.withLock +import kotlinx.datetime.TimeZone +import kotlin.uuid.Uuid + +class AuthProvider private constructor() { + private val _server = mutableStateOf(null) + private val _protocolVersion = mutableStateOf(null) + private val _refresh = mutableStateOf(null) + private val _access = mutableStateOf(null) + private val _session = mutableStateOf(0) + private val _lock = ReentrantLock() + + private val _store = persistentStore() + private val _refreshAccessor = _store.refreshToken() + private val _serverAccessor = _store.server() + var access = _access.immutable() + + val server = _server.immutable() + val protocolVersion = _protocolVersion.immutable() + val refresh = _refresh.immutable() + val session = _session.immutable() + + init { + _refresh.value = _refreshAccessor.load() + println("Refresh token: ${_refresh.value}") + _server.value = _serverAccessor.load() + println("Server: ${_server.value}") + if(_server.value == null) onLogout() + } + + fun setServer(server: String, protocol: String) { + println("Setting server to $server, protocol version $protocol") + _server.value = server + _protocolVersion.value = protocol + _serverAccessor.save(server) + onLogout() + } + + fun onLogin(tokens: TokenResponse) { + _lock.withLock { + if (server.value == null) throw IllegalStateException("Server is null, cannot log in.") + println("Saving new refresh token (${tokens.refresh})") + _refresh.value = tokens.refresh + _access.value = tokens.access + _refreshAccessor.save(tokens.refresh) + _session.value++ + } + } + + fun onRefresh(tokens: TokenResponse) { + _lock.withLock { + println("Saving new refresh token (${tokens.refresh})") + _refresh.value = tokens.refresh + _access.value = tokens.access + _refreshAccessor.save(tokens.refresh) + } + } + + fun onLogout() { + _lock.withLock { + _refresh.value = null + _access.value = null + _refreshAccessor.erase() + _session.value++ + } + } + + fun asBearer(): BearerTokens? = _lock.withLock { + println("Bearer tokens requested (access: ${access.value}, refresh: ${_refresh.value})") + BearerTokens( + accessToken = _access.value ?: return null, + refreshToken = _refresh.value?.toString() ?: return null + ) + } + + fun clearServer() { + _server.value = null + _serverAccessor.erase() + } + + companion object { + private var _instance: AuthProvider? = null + private val _lock = ReentrantLock() + + fun construct() = _lock.withLock { + if(_instance != null) throw IllegalStateException("AuthProvider is already constructed.") + _instance = AuthProvider() + _instance!! + } + + fun get() = _instance ?: throw IllegalStateException("AuthProvider is not yet constructed.") + } +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/Client.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/Client.kt new file mode 100644 index 0000000..0956bd2 --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/Client.kt @@ -0,0 +1,112 @@ +package com.jaytux.phoebench.clients + +import com.jaytux.phoebench.common.ApiRoute +import com.jaytux.phoebench.common.Either +import com.jaytux.phoebench.common.EmptyRequest +import com.jaytux.phoebench.common.ErrorResponse +import com.jaytux.phoebench.common.IClient +import com.jaytux.phoebench.common.LogoutRequest +import com.jaytux.phoebench.common.RefreshRequest +import com.jaytux.phoebench.common.Routes +import com.jaytux.phoebench.common.error +import com.jaytux.phoebench.common.foldSuspend +import io.ktor.client.HttpClient +import io.ktor.client.plugins.api.createClientPlugin +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.authProvider +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import io.ktor.client.plugins.auth.providers.bearer +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.CancellationException +import kotlinx.atomicfu.locks.ReentrantLock +import kotlinx.atomicfu.locks.withLock +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.datetime.TimeZone +import kotlin.uuid.Uuid + +class Client private constructor(private val _auth: AuthProvider) { + private val _authClient = platformClient { + install(ContentNegotiation) { json() } + } + private val _client = platformClient { + var tryingRefresh = false + install(ContentNegotiation) { json() } + install(Auth) { + bearer { + loadTokens { + val res = _auth.asBearer() + println("Client requested bearer tokens and got $res") + res + } + + refreshTokens { + if(tryingRefresh) { + _auth.onLogout() + null + } + else { + tryingRefresh = true + val ref = _auth.refresh.value ?: return@refreshTokens null + + val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({ + if(it.msg.startsWith(COROUTINE_CANCELLED)) println("Coro calling refresh was cancelled") + _auth.onLogout() + null + }) { + _auth.onRefresh(it) + val res = _auth.asBearer() + println("Client requested bearer tokens (from refresh) and got $res") + res + } + tryingRefresh = false + res + } + } + } + } + } + + private var _counter = 0 + + private suspend fun callRoute(using: HttpClient, route: ApiRoute, body: TReq, wasInternal: Boolean = false): Either { + val ctr = _counter++ + return try { + println("Calling route ${route.pattern} with client $using, request ID=$ctr (internal request: $wasInternal)") + val client = IClient.Default(using, _auth.server.value ?: throw IllegalStateException("No server URL set.")) + val res = route.call(client, body, ctr) + res + } + catch(e: CancellationException) { + println("Call to ${route.pattern} [$ctr] was cancelled") + ErrorResponse(COROUTINE_CANCELLED).error() + } + catch(e: Exception) { + println("Call to ${route.pattern} [$ctr] ran into an exception") + ErrorResponse(e.message ?: "Unknown error.").error() + } + } + + suspend fun callRoute(route: ApiRoute, body: TReq): Either = + callRoute(_client, route, body) + + companion object { + private val _sync = ReentrantLock() + private var _instance: Client? = null + const val COROUTINE_CANCELLED = "!!CORO CANCELLATION!!" + + fun construct(auth: AuthProvider): Client { + _sync.withLock { + if(_instance == null) { + _instance = Client(auth) + return _instance!! + } + else throw IllegalStateException("Client already constructed") + } + } + + fun get() = _instance ?: throw IllegalStateException("Client not constructed yet") + } +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.kt new file mode 100644 index 0000000..aa44dca --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.kt @@ -0,0 +1,23 @@ +package com.jaytux.phoebench.clients + +import androidx.compose.ui.platform.ClipEntry +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import kotlin.uuid.Uuid + +interface IStore { + interface IStoredProperty { + fun load(): T? + fun save(value: T) + fun erase() + } + + fun refreshToken(): IStoredProperty + fun server(): IStoredProperty +} + +expect fun persistentStore(): IStore + +expect fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient + +expect suspend fun String.toClipEntry(): ClipEntry \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/SnackProvider.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/SnackProvider.kt new file mode 100644 index 0000000..9fcc13c --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/SnackProvider.kt @@ -0,0 +1,51 @@ +package com.jaytux.phoebench.clients + +import com.jaytux.phoebench.common.Either +import com.jaytux.phoebench.common.ErrorResponse +import com.jaytux.phoebench.common.fold +import com.jaytux.phoebench.common.foldSuspend +import kotlinx.atomicfu.locks.ReentrantLock +import kotlinx.atomicfu.locks.withLock +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +class SnackProvider private constructor() { + private val _snacks = Channel(Channel.BUFFERED, BufferOverflow.DROP_OLDEST) + val snacks = _snacks.receiveAsFlow() + + fun send(snack: String) { +// println("Received snack message '$snack'") +// Exception().printStackTrace() + if(snack.startsWith(Client.COROUTINE_CANCELLED)) _snacks.trySend("Request was cancelled") + else _snacks.trySend(snack) + } + + companion object { + private var _instance: SnackProvider? = null + private val _lock = ReentrantLock() + + fun construct(): SnackProvider { + _lock.withLock { + if(_instance == null) { + _instance = SnackProvider() + return _instance!! + } + else throw IllegalStateException("SnackProvider already constructed.") + } + } + + fun get() = _instance ?: throw IllegalStateException("SnackProvider not constructed.") + + inline fun Either.snackOnError() = + fold({ get().send(it.msg) }) {} + + inline fun Either.snackOr(process: (T) -> Unit) = + fold({ get().send(it.msg) }) { process(it) } + suspend inline fun Either.snackOrSuspend(process: suspend (T) -> Unit) = + foldSuspend({ get().send(it.msg) }) { process(it) } + + suspend inline fun Either.snackMaybeSuspend(): T? = + foldSuspend({ get().send(it.msg); null }) { it } + } +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/Util.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/Util.kt new file mode 100644 index 0000000..c92ce40 --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/Util.kt @@ -0,0 +1,85 @@ +package com.jaytux.phoebench.clients + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.jaytux.phoebench.common.Either +import com.jaytux.phoebench.common.map +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.format.MonthNames +import kotlinx.datetime.format.Padding +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime +import kotlin.math.absoluteValue +import kotlin.math.roundToInt +import kotlin.random.Random +import kotlin.random.nextInt +import kotlin.time.Clock +import kotlin.time.Instant + +fun MutableState.immutable(): State = this +fun T.ignore() {} + +inline fun ViewModel.withScope(crossinline block: suspend () -> R) = viewModelScope.launch { + withContext(Dispatchers.Unconfined) { block() } +} + +val formatter = LocalDateTime.Format { + val noPad = Padding.NONE + day(noPad); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED); char(' '); year(noPad); char(' ') + hour(); char(':'); minute(); char(':'); second() +} + +fun Instant.fmt(): String = this.toLocalDateTime(systemTz).format(formatter) + +val systemTz = TimeZone.currentSystemDefault() + +fun Instant.isPast() = compareTo(Clock.System.now()) <= 0 + +infix fun T.nonEq(other: T) = if(this == other) null else this + +inline fun Either.ignoreValue(): Either = map {} + +private const val HEX_ARRAY = "0123456789abcdef" + +fun Color.hexString() = "#${(this.toArgb() and 0xFFFFFF).toHexString(HexFormat.UpperCase).takeLast(6)}" + +fun randomColor() = Color( + red = Random.nextInt(0..255), + green = Random.nextInt(0..255), + blue = Random.nextInt(0..255) +) + +fun Color.darken(factor: Float): Color { + val inv = 1.0f - factor + return copy(red = red * inv, green = green * inv, blue = blue * inv) +} + +fun dualLerp(min: Instant, max: Instant, low: Float, high: Float): Pair { + if(max == min || (low - high).absoluteValue < 1e-6f) return min to max + val delta = if(max > min) (max - min) else (min - max) + + val lowTime = min + delta * low.toDouble() + val highTime = min + delta * high.toDouble() + + return if(lowTime < highTime) (lowTime to highTime) else (highTime to lowTime) +} + +fun Pair.fmtRange() = "Between ${first.fmt()} and ${second.fmt()}" + +infix fun Instant.inRange(range: Pair) = range.first <= this && this <= range.second + +fun Float.fmt(): String { + val integer = toInt() + val decimals = this - integer + val decInt = (decimals * 1000).roundToInt().toFloat() / 1000f + return (integer + decInt).toString() +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt new file mode 100644 index 0000000..5e47fdd --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt @@ -0,0 +1,161 @@ +package com.jaytux.phoebench.clients.data + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.Clipboard +import androidx.lifecycle.ViewModel +import com.jaytux.phoebench.clients.AuthProvider +import com.jaytux.phoebench.clients.Client +import com.jaytux.phoebench.clients.SnackProvider +import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOnError +import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr +import com.jaytux.phoebench.clients.immutable +import com.jaytux.phoebench.clients.toClipEntry +import com.jaytux.phoebench.clients.withScope +import com.jaytux.phoebench.common.HomeResponse +import com.jaytux.phoebench.common.InviteListResponse +import com.jaytux.phoebench.common.UserListResponse +import kotlin.uuid.Uuid + +class HomeVM( + private val _client: Client = Client.get(), + private val _auth: AuthProvider = AuthProvider.get(), + private val _snack: SnackProvider = SnackProvider.get(), + private val _repo: IHomeRepo = IHomeRepo.default(_client) +) : ViewModel() { + private val _username = mutableStateOf(null) + private val _isAdmin = mutableStateOf(false) + private val _projectLimit = mutableStateOf(0) + private val _ownProjects = mutableStateOf(listOf()) + private val _publicProjects = mutableStateOf(listOf()) + + private val _users = mutableStateOf(listOf()) + private val _invites = mutableStateOf(listOf()) + + private var _tokenWasNull = _auth.refresh.value == null + + val username = _username.immutable() + val isAdmin = _isAdmin.immutable() + val projectLimit = _projectLimit.immutable() + val ownProjects = _ownProjects.immutable() + val publicProjects = _publicProjects.immutable() + + val invites = _invites.immutable() + val users = _users.immutable() + + init { + refresh() + } + + private fun resetAdmin() { + _users.value = listOf() + _invites.value = listOf() + } + + fun reset() { + resetAdmin() + _username.value = null + _isAdmin.value = false + _projectLimit.value = 0 + _ownProjects.value = listOf() + _publicProjects.value = listOf() + } + + fun refreshIfNeeded() { + println("Check for refresh: $_tokenWasNull && ${_auth.refresh.value != null}") + if(_tokenWasNull && _auth.refresh.value != null) refresh() + _tokenWasNull = _auth.refresh.value == null + } + + fun refresh() { + withScope { + resetAdmin() + _repo.getHome().snackOr { + println("Received home response $it") + _username.value = it.username + _isAdmin.value = it.isAdmin + _projectLimit.value = it.projectLimit + _ownProjects.value = it.ownProjects + _publicProjects.value = it.publicProjects + refreshAdmin() + } + } + } + + fun refreshAdmin() { + withScope { + if(_isAdmin.value) { + _repo.inviteList().snackOr { r -> + _invites.value = r.uuids.map { + it.copy(expires = it.expires) + } + } + + _repo.userList().snackOr { r -> + _users.value = r.users + } + } + else { + _invites.value = listOf() + _users.value = listOf() + } + } + } + + fun mkInvite(clipboard: Clipboard, asAdmin: Boolean) { + withScope { + _repo.newInvite(asAdmin).snackOr { + refreshAdmin() + clipboard.setClipEntry(it.uuid.toString().toClipEntry()) + _snack.send("Copied new invite to clipboard (${it.uuid})") + } + } + } + + fun deleteInvite(id: Uuid) { + withScope { + _repo.deleteInvite(id).snackOr { + refreshAdmin() + } + } + } + + fun deleteUser(id: Uuid) { + withScope { + _repo.deleteUser(id).snackOr { + refreshAdmin() + } + } + } + + fun updateUser(id: Uuid, isAdmin: Boolean?, projectLimit: Int?) { + withScope { + _repo.updateUser(id, isAdmin, projectLimit).snackOr { + refreshAdmin() + } + } + } + + fun logout(everywhere: Boolean) { + withScope { + if (everywhere) { + _repo.logoutEverywhere().snackOnError() + } + else { + _auth.refresh.value?.let { + _repo.logout(it) + } ?: { + _snack.send("No refresh token.") + } + } + _auth.onLogout() + } + } + + fun mkProject(name: String, isPublic: Boolean) { + withScope { + _repo.newProject(name, isPublic).snackOr { + refresh() + } + } + } +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IHomeRepo.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IHomeRepo.kt new file mode 100644 index 0000000..0426960 --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IHomeRepo.kt @@ -0,0 +1,69 @@ +package com.jaytux.phoebench.clients.data + +import com.jaytux.phoebench.clients.Client +import com.jaytux.phoebench.clients.ignoreValue +import com.jaytux.phoebench.common.Either +import com.jaytux.phoebench.common.EmptyRequest +import com.jaytux.phoebench.common.ErrorResponse +import com.jaytux.phoebench.common.HomeResponse +import com.jaytux.phoebench.common.InviteListResponse +import com.jaytux.phoebench.common.InviteRequest +import com.jaytux.phoebench.common.LogoutRequest +import com.jaytux.phoebench.common.ProjectRequest +import com.jaytux.phoebench.common.Routes +import com.jaytux.phoebench.common.UserListResponse +import com.jaytux.phoebench.common.UserUpdateRequest +import com.jaytux.phoebench.common.UuidResponse +import kotlin.uuid.Uuid + +interface IHomeRepo { + suspend fun getHome(): Either + suspend fun logout(refresh: Uuid): Either + suspend fun logoutEverywhere(): Either + + suspend fun inviteList(): Either + suspend fun newInvite(isAdmin: Boolean): Either + suspend fun deleteInvite(id: Uuid): Either + + suspend fun userList(): Either + suspend fun updateUser(id: Uuid, isAdmin: Boolean? = null, projectLimit: Int? = null): Either + suspend fun deleteUser(id: Uuid): Either + + suspend fun newProject(name: String, isPublic: Boolean): Either + + companion object { + class Default(private val _client: Client) : IHomeRepo { + override suspend fun getHome(): Either = + _client.callRoute(Routes.home, EmptyRequest()) + + override suspend fun logout(refresh: Uuid): Either = + _client.callRoute(Routes.Auth.logout, LogoutRequest(refresh)).ignoreValue() + + override suspend fun logoutEverywhere(): Either = + _client.callRoute(Routes.Auth.logoutEverywhere, EmptyRequest()).ignoreValue() + + override suspend fun inviteList(): Either = + _client.callRoute(Routes.Auth.Invite.list, EmptyRequest()) + + override suspend fun newInvite(isAdmin: Boolean): Either = + _client.callRoute(Routes.Auth.Invite.new, InviteRequest(isAdmin)) + + override suspend fun deleteInvite(id: Uuid): Either = + _client.callRoute(Routes.Auth.Invite.delete, id).ignoreValue() + + override suspend fun userList(): Either = + _client.callRoute(Routes.Auth.User.list, EmptyRequest()) + + override suspend fun updateUser(id: Uuid, isAdmin: Boolean?, projectLimit: Int?): Either = + _client.callRoute(Routes.Auth.User.update, id to UserUpdateRequest(projectLimit, isAdmin)).ignoreValue() + + override suspend fun deleteUser(id: Uuid): Either = + _client.callRoute(Routes.Auth.User.delete, id).ignoreValue() + + override suspend fun newProject(name: String, isPublic: Boolean): Either = + _client.callRoute(Routes.Project.new, ProjectRequest(name, isPublic)).ignoreValue() + } + + fun default(client: Client) = Default(client) + } +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IProjectRepo.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IProjectRepo.kt new file mode 100644 index 0000000..a71997f --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IProjectRepo.kt @@ -0,0 +1,77 @@ +package com.jaytux.phoebench.clients.data + +import androidx.compose.ui.graphics.Color +import com.jaytux.phoebench.clients.Client +import com.jaytux.phoebench.clients.hexString +import com.jaytux.phoebench.clients.ignoreValue +import com.jaytux.phoebench.common.Either +import com.jaytux.phoebench.common.EntryRequest +import com.jaytux.phoebench.common.EntryResponse +import com.jaytux.phoebench.common.ErrorResponse +import com.jaytux.phoebench.common.LabelRequest +import com.jaytux.phoebench.common.LabelResponse +import com.jaytux.phoebench.common.PartialEntryRequest +import com.jaytux.phoebench.common.PartialLabelRequest +import com.jaytux.phoebench.common.PartialProjectRequest +import com.jaytux.phoebench.common.ProjectResponse +import com.jaytux.phoebench.common.Routes +import com.jaytux.phoebench.common.TimeUnit +import kotlinx.datetime.TimeZone +import kotlin.time.Instant +import kotlin.uuid.Uuid + +interface IProjectRepo { + suspend fun get(): Either + suspend fun update(name: String? = null, isPublic: Boolean? = null): Either + suspend fun delete(): Either + + suspend fun newLabel(name: String, color: Color): Either + suspend fun updateLabel(id: Uuid, name: String? = null, color: Color? = null): Either + suspend fun deleteLabel(id: Uuid): Either + + suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List, measurements: List, + unit: TimeUnit): Either + suspend fun updateEntry(id: Uuid, label: Uuid? = null, timestamp: Instant? = null, warmups: List? = null, + measurements: List? = null, unit: TimeUnit? = null): Either + suspend fun deleteEntry(id: Uuid): Either + + companion object { + class Default(private val _client: Client, private val _projectId: Uuid) : IProjectRepo { + override suspend fun get(): Either = + _client.callRoute(Routes.Project.get, _projectId) + + override suspend fun update(name: String?, isPublic: Boolean?): Either = + _client.callRoute(Routes.Project.update, _projectId to PartialProjectRequest(name, isPublic)).ignoreValue() + + override suspend fun delete(): Either = + _client.callRoute(Routes.Project.delete, _projectId).ignoreValue() + + override suspend fun newLabel(name: String, color: Color): Either = + _client.callRoute(Routes.Label.new, LabelRequest(name, color.hexString(), _projectId)) + + override suspend fun updateLabel(id: Uuid, name: String?, color: Color?): Either = + _client.callRoute(Routes.Label.update, id to PartialLabelRequest(name, color?.hexString())).ignoreValue() + + override suspend fun deleteLabel(id: Uuid): Either = + _client.callRoute(Routes.Label.delete, id).ignoreValue() + + override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List, + measurements: List, unit: TimeUnit + ): Either = + _client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit)) + + override suspend fun updateEntry(id: Uuid, label: Uuid?, timestamp: Instant?, + warmups: List?, measurements: List?, + unit: TimeUnit? + ): Either = + _client.callRoute(Routes.Entry.update, + id to PartialEntryRequest(label, timestamp, null, warmups, measurements, unit) + ).ignoreValue() + + override suspend fun deleteEntry(id: Uuid): Either = + _client.callRoute(Routes.Entry.delete, id).ignoreValue() + } + + fun default(client: Client, projectId: Uuid) = Default(client, projectId) + } +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/MutableStateSet.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/MutableStateSet.kt new file mode 100644 index 0000000..920ad3e --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/MutableStateSet.kt @@ -0,0 +1,59 @@ +package com.jaytux.phoebench.clients.data + +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import com.jaytux.phoebench.clients.immutable + +class MutableStateSet { + private val _internal = mutableStateMapOf() + private val _revision = mutableStateOf(0) + val revision = _revision.immutable() + + fun add(element: T) { + _internal[element] = Unit + _revision.value++ + } + + fun remove(element: T) { + _internal.remove(element) + _revision.value++ + } + + fun toggle(element: T) { + if(element in this) remove(element) + else add(element) + _revision.value++ + } + + fun addAll(elements: Collection) { + _internal.putAll(elements.map { it to Unit }) + _revision.value++ + } + + fun removeAll(elements: Collection) { + elements.forEach { _internal.remove(it) } + _revision.value++ + } + + fun clear() { + _internal.clear() + _revision.value++ + } + + val size: Int + get() = _internal.size + + fun isEmpty(): Boolean = _internal.isEmpty() + + operator fun contains(element: T): Boolean = element in _internal + + fun toSet(): Set = _internal.keys +} + +fun mutableStateSetOf(vararg elements: T): MutableStateSet = mutableStateSetFrom(elements.toList()) + +fun mutableStateSetFrom(coll: Collection): MutableStateSet { + val res = MutableStateSet() + res.addAll(coll) + return res +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt new file mode 100644 index 0000000..64f0c98 --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt @@ -0,0 +1,154 @@ +package com.jaytux.phoebench.clients.data + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.ViewModel +import com.jaytux.phoebench.clients.AuthProvider +import com.jaytux.phoebench.clients.Client +import com.jaytux.phoebench.clients.SnackProvider +import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr +import com.jaytux.phoebench.clients.hexString +import com.jaytux.phoebench.clients.immutable +import com.jaytux.phoebench.clients.systemTz +import com.jaytux.phoebench.clients.withScope +import com.jaytux.phoebench.common.EntryResponse +import com.jaytux.phoebench.common.LabelResponse +import com.jaytux.phoebench.common.TimeUnit +import kotlin.time.Clock +import kotlin.time.Instant +import kotlin.uuid.Uuid + +class ProjectVM( + private val _id: Uuid, + private val _auth: AuthProvider = AuthProvider.get(), + private val _snack: SnackProvider = SnackProvider.get(), + private val _client: Client = Client.get(), + private val _repo: IProjectRepo = IProjectRepo.default(_client, _id), + private val _forceBack: () -> Unit +) : ViewModel() { + data class Label(val id: Uuid, val name: String, val colorStr: String, val uiColor: Color = parseColor(colorStr)) { + companion object { + private val _snack by lazy { SnackProvider.get() } + private val _errColor = Color(red = 252, green = 20, blue = 182) + + private fun errorColor(str: String): Color { + _snack.send("Project uses invalid color: $str") + return _errColor + } + + private fun parseColor(str: String): Color { + if(str[0] != '#') return errorColor(str) + val r = str.substring(1, 3).toIntOrNull(16) ?: return errorColor(str) + val g = str.substring(3, 5).toIntOrNull(16) ?: return errorColor(str) + val b = str.substring(5, 7).toIntOrNull(16) ?: return errorColor(str) + return Color(red = r, green = g, blue = b) + } + + fun fromResponse(it: LabelResponse) = Label(it.id, it.name, it.color) + + val invalid = Label(Uuid.fromLongs(0L, 0L), "", _errColor.hexString(), _errColor) + } + } + + data class Entry(val id: Uuid, val label: Label, val timeStamp: Instant, val warmups: List, + val measurements: List, val nativeUnit: TimeUnit) { + companion object { + fun fromResponse(it: EntryResponse, map: Map) = Entry( + it.id, map[it.labelId] ?: Label.invalid, it.timestamp, + it.warmups, it.measurements, it.unit + ) + } + } + + private val _name = mutableStateOf(null) + private val _owner = mutableStateOf(null) + private val _public = mutableStateOf(false) + private val _editable = mutableStateOf(false) + private val _labels = mutableStateOf(mapOf()) + private val _entries = mutableStateOf(listOf()) + + val name = _name.immutable() + val owner = _owner.immutable() + val public = _public.immutable() + val editable = _editable.immutable() + val labels = _labels.immutable() + val entries = _entries.immutable() + + init { + refresh() + } + + fun refresh() { + withScope { + _repo.get().snackOr { + _name.value = it.name + _owner.value = it.owner.name + _public.value = it.isPublic + _editable.value = it.isEditable + _labels.value = it.usedLabels.associate { l -> l.id to Label.fromResponse(l) } + _entries.value = it.entries.map { e -> Entry.fromResponse(e, _labels.value) } + } + } + } + + fun update(name: String?, isPublic: Boolean?) { + withScope { + _repo.update(name, isPublic).snackOr { refresh() } + } + } + + fun delete() { + withScope { + _repo.delete().snackOr { _forceBack() } + } + } + + fun mkLabel(name: String, color: Color) { + withScope { + _repo.newLabel(name, color).snackOr { + _labels.value += it.id to Label(it.id, it.name, it.color) + } + } + } + + fun updateLabel(id: Uuid, name: String?, color: Color?) { + withScope { + val old = _labels.value[id] ?: return@withScope + _repo.updateLabel(id, name, color).snackOr { + _labels.value += id to Label(id, name ?: old.name, color?.hexString() ?: old.colorStr, color ?: old.uiColor) + } + } + } + + fun deleteLabel(id: Uuid) { + withScope { + _repo.deleteLabel(id).snackOr { + _labels.value -= id + } + } + } + + fun mkEntry(label: Label, warmups: List, measurements: List, unit: TimeUnit) { + withScope { + _repo.newEntry(label.id, Clock.System.now(), warmups, measurements, unit).snackOr { + _entries.value += Entry.fromResponse(it, _labels.value) + } + } + } + + fun updateEntry(id: Uuid, label: Label?, warmups: List?, measurements: List?, unit: TimeUnit?) { + withScope { + _repo.updateEntry(id, label?.id, Clock.System.now(), warmups, measurements, unit).snackOr { + refresh() + } + } + } + + fun deleteEntry(id: Uuid) { + withScope { + _repo.deleteEntry(id).snackOr { + _entries.value = _entries.value.filter { it.id != id } + } + } + } +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/theme/Constants.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/theme/Constants.kt new file mode 100644 index 0000000..8dc5b45 --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/theme/Constants.kt @@ -0,0 +1,5 @@ +package com.jaytux.phoebench.clients.theme + +import androidx.compose.ui.graphics.Color + +val linkColor = Color(0xFF64B5F6) \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt new file mode 100644 index 0000000..bf054f0 --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt @@ -0,0 +1,694 @@ +package com.jaytux.phoebench.clients.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SecondaryScrollableTabRow +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.viewmodel.compose.viewModel +import com.composables.icons.lucide.ChevronLeft +import com.composables.icons.lucide.Delete +import com.composables.icons.lucide.LogOut +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Pencil +import com.composables.icons.lucide.Plus +import com.composables.icons.lucide.ShieldPlus +import com.composables.icons.lucide.Trash +import com.composables.icons.lucide.X +import com.jaytux.phoebench.clients.AuthProvider +import com.jaytux.phoebench.clients.Client +import com.jaytux.phoebench.clients.SnackProvider +import com.jaytux.phoebench.clients.data.HomeVM +import com.jaytux.phoebench.clients.data.mutableStateSetOf +import com.jaytux.phoebench.clients.fmt +import com.jaytux.phoebench.clients.isPast +import com.jaytux.phoebench.clients.nonEq +import com.jaytux.phoebench.clients.theme.linkColor +import com.jaytux.phoebench.common.EmptyRequest +import com.jaytux.phoebench.common.HomeResponse +import com.jaytux.phoebench.common.LoginRequest +import com.jaytux.phoebench.common.NamedID +import com.jaytux.phoebench.common.ProtocolVersion +import com.jaytux.phoebench.common.RefreshRequest +import com.jaytux.phoebench.common.Routes +import com.jaytux.phoebench.common.SignupRequest +import com.jaytux.phoebench.common.UserListResponse +import com.jaytux.phoebench.common.fold +import kotlinx.coroutines.launch +import kotlinx.datetime.TimeZone +import kotlin.uuid.Uuid + +@Composable +fun RootView(auth: AuthProvider, client: Client) = Surface(tonalElevation = 1.dp) { + var isSignup by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val server by auth.server + val refresh by auth.refresh + val scope = rememberCoroutineScope() + + suspend fun onServerSelect(server: String): Boolean { + if(!server.startsWith("https://") && !server.startsWith("http://0.0.0.0") && !server.startsWith("http://localhost")) { + error = "Server connection might be insecure. PhoeBench is only supported over https." + return false + } + + auth.setServer(server, ProtocolVersion.VERSION) + return client.callRoute(Routes.handshake, EmptyRequest()).fold({ + auth.clearServer() + error = it.msg + false + }) { + if(it.version != ProtocolVersion.VERSION) { + error = "Mismatching protocol version." + false + } + else { + auth.setServer(server, it.version) + true + } + } + } + + fun onLogin(server: String, user: String, pass: String) { + scope.launch { + if(!onServerSelect(server)) return@launch + client.callRoute(Routes.Auth.login, LoginRequest(user, pass)).fold({ + error = it.msg + }) { + auth.onLogin(it) + } + } + } + + fun onSignup(server: String, invite: String, user: String, pass: String) { + scope.launch { + if(!onServerSelect(server)) return@launch + val inviteUuid = Uuid.parseOrNull(invite) ?: run { + error = "Invalid invite code format." + return@launch + } + client.callRoute(Routes.Auth.signup, SignupRequest(inviteUuid, user, pass)).fold({ + error = it.msg + }) { + auth.onLogin(it) + } + } + } + + fun onSwitch() { + isSignup = !isSignup + error = null + } + + Box(Modifier.fillMaxSize()) { + Surface(Modifier.align(Alignment.Center).padding(5.dp), shape = MaterialTheme.shapes.medium, tonalElevation = 5.dp) { + refresh?.let { + AuthenticatedRoot() + } ?: run { + if(isSignup) SignupView(server, error, ::onSwitch, ::onSignup) + else LoginView(server, error, ::onSwitch, ::onLogin) + } + } + } +} + +@Composable +fun LoginView(server: String?, error: String?, onSwitchSignup: () -> Unit, onLogin: (server: String, user: String, pass: String) -> Unit) { + var serverUrl by remember { mutableStateOf(server ?: "https://") } + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + val enabled = ((serverUrl.startsWith("https://") || serverUrl.startsWith("http://0.0.0.0")) || serverUrl.startsWith("http://localhost")) && username.isNotBlank() && password.isNotBlank() + + Column(Modifier.padding(5.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min), horizontalAlignment = Alignment.CenterHorizontally) { + Text("Log in", style = MaterialTheme.typography.headlineMedium) + + OutlinedTextField(serverUrl, { serverUrl = it }, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true) + + OutlinedTextField(username, { username = it }, Modifier.fillMaxWidth(), label = { Text("Username") }, singleLine = true) + OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") }, visualTransformation = PasswordVisualTransformation(), singleLine = true) + + Button({ onLogin(serverUrl.trim(), username.trim(), password) }, Modifier.fillMaxWidth(), enabled = enabled) { + Text("Log in") + } + + error?.let { + Text(it, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.error) + } + + Row { + Text("No account yet? ") + Text("Sign up", Modifier.clickable { onSwitchSignup() }, color = linkColor, textDecoration = TextDecoration.Underline) + } + } +} + +@Composable +fun SignupView(server: String?, error: String?, onSwitchLogin: () -> Unit, onSignup: (server: String, invite: String, user: String, pass: String) -> Unit) { + var serverUrl by remember { mutableStateOf(server ?: "https://") } + var inviteCode by remember { mutableStateOf("") } + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + val enabled = ((serverUrl.startsWith("https://") || serverUrl.startsWith("http://0.0.0.0")) || serverUrl.startsWith("http://localhost")) && inviteCode.isNotBlank() && username.isNotBlank() && password.isNotBlank() + + Column(Modifier.padding(5.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min), horizontalAlignment = Alignment.CenterHorizontally) { + Text("Sign up", style = MaterialTheme.typography.headlineMedium) + + OutlinedTextField(serverUrl, { serverUrl = it }, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true) + + OutlinedTextField(inviteCode, { inviteCode = it }, Modifier.fillMaxWidth(), label = { Text("Invite code") }, singleLine = true) + OutlinedTextField(username, { username = it }, Modifier.fillMaxWidth(), label = { Text("Username") }, singleLine = true) + OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") }, visualTransformation = PasswordVisualTransformation(), singleLine = true) + + Button({ onSignup(serverUrl.trim(), inviteCode.trim(), username.trim(), password) }, Modifier.fillMaxWidth(), enabled = enabled) { + Text("Sign up") + } + + error?.let { + Text(it, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.error) + } + + Row { + Text("Already have an account? ") + Text("Log in", Modifier.clickable { onSwitchLogin() }, color = linkColor, textDecoration = TextDecoration.Underline) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun AuthenticatedRoot() { + val snacks = SnackProvider.get() + val state = remember { SnackbarHostState() } + val vm = viewModel { HomeVM() } + var loggingOut by remember { mutableStateOf(false) } + var currentProject by remember { mutableStateOf(null) } + + LaunchedEffect(snacks) { + snacks.snacks.collect { state.showSnackbar(it) } + } + + fun leaveProject() { + currentProject = null + vm.refresh() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("PhoeBench", style = MaterialTheme.typography.headlineLarge) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + actions = { + IconButton({ loggingOut = true }) { + Icon(Lucide.LogOut, "Log out") + } + }, + navigationIcon = { + if(currentProject != null) { + IconButton(::leaveProject) { + Icon(Lucide.ChevronLeft, "Back") + } + } + } + ) + }, + snackbarHost = { + SnackbarHost(state) + } + ) { insets -> + Surface(Modifier.padding(insets), color = MaterialTheme.colorScheme.surface) { + currentProject?.let { + BackHandler { leaveProject() } + ProjectView(it, ::leaveProject) + } ?: run { + HomeView { currentProject = it } + } + } + } + + if(loggingOut) ConfirmLogoutDialog({ loggingOut = false }, vm::logout) +} + +@Composable +fun ConfirmLogoutDialog(onCancel: () -> Unit, onLogout: (everywhere: Boolean) -> Unit) { + Dialog(onDismissRequest = onCancel) { + Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(8.dp).widthIn(min = 600.dp).width(IntrinsicSize.Min)) { + Text("Confirm logout", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(10.dp)) + Text("Are you sure you want to log out?") + Spacer(Modifier.height(10.dp)) + Row { + Button(onCancel, Modifier.weight(0.33f)) { + Text("Cancel") + } + Spacer(Modifier.width(5.dp)) + Button({ onLogout(true); onCancel() }, Modifier.weight(0.33f)) { + Text("Log out everywhere") + } + Spacer(Modifier.width(5.dp)) + Button({ onLogout(false); onCancel() }, Modifier.weight(0.33f)) { + Text("Log out") + } + } + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun HomeView(onSelectProject: (Uuid) -> Unit) { + val vm = viewModel { HomeVM() } + + val auth = AuthProvider.get() + val username by vm.username + val isAdmin by vm.isAdmin + val projectLimit by vm.projectLimit + val ownProjects by vm.ownProjects + val publicProjects by vm.publicProjects + val session by auth.session + + var ownFilter by remember { mutableStateOf("") } + var publicFilter by remember { mutableStateOf("") } + var publicUserFilterStr by remember { mutableStateOf("") } + val publicUserFilter = remember { mutableStateSetOf() } + val possibleUsers = remember { mutableStateListOf() } + + var visibleOwnProjects by remember { mutableStateOf(ownProjects) } + var visiblePublicProjects by remember { mutableStateOf(publicProjects) } + + LaunchedEffect(ownFilter, ownProjects) { + visibleOwnProjects = + if(ownFilter == "") ownProjects + else ownProjects.filter { ownFilter in it.name } + } + + LaunchedEffect(publicFilter, publicUserFilter.revision, publicProjects) { + visiblePublicProjects = when { + publicFilter == "" && publicUserFilter.isEmpty() -> publicProjects + publicFilter == "" -> publicProjects.filter { it.owner in publicUserFilter } + publicUserFilter.isEmpty() -> publicProjects.filter { publicFilter in it.name } + else -> publicProjects.filter { publicFilter in it.name && it.owner in publicUserFilter } + } + } + + LaunchedEffect(publicProjects, publicUserFilterStr) { + val allPublicUsers = publicProjects.map { it.owner }.toSet().sortedBy { it.name } + possibleUsers.clear() + if(publicUserFilterStr == "") { + possibleUsers.addAll(allPublicUsers) + } + else { + possibleUsers.addAll(allPublicUsers.filter { publicUserFilterStr in it.name }) + } + } + + LaunchedEffect(session) { vm.refresh() } + + var creatingProject by remember { mutableStateOf(false) } + Column(Modifier.padding(all = 15.dp)) { + Row(Modifier.height(IntrinsicSize.Min)) { + Text(username?.let { "Welcome, $it" } ?: "Welcome", style = MaterialTheme.typography.headlineMedium) + } + Spacer(Modifier.height(10.dp)) + + Row(Modifier.weight(0.6f)) { + var leftHeaderHeight by remember { mutableStateOf(0.dp) } + var rightFilterHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + Surface( + Modifier.padding(5.dp).weight(0.5f), + shape = MaterialTheme.shapes.medium, + tonalElevation = 5.dp + ) { + Column(Modifier.padding(15.dp).fillMaxSize()) { + Row(Modifier.onGloballyPositioned { leftHeaderHeight = with(density) { it.size.height.toDp() } }) { + Row(Modifier.weight(1f)) { + Text("Own projects: ", style = MaterialTheme.typography.headlineSmall) + Text( + "${ownProjects.size} / ${if (projectLimit == -1) "∞" else projectLimit}", + Modifier.align(Alignment.Bottom) + ) + } + IconButton( + { creatingProject = true }, + enabled = projectLimit == -1 || ownProjects.size < projectLimit + ) { + Icon(Lucide.Plus, "Create project") + } + } + Spacer(Modifier.height(5.dp)) + Row(Modifier.height(rightFilterHeight)) { + OutlinedTextField( + ownFilter, + { ownFilter = it }, + Modifier.fillMaxSize(), + label = { Text("Filter by name...") }) + } + Spacer(Modifier.height(5.dp)) + + if (ownProjects.isEmpty()) { + Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { + Text("No projects yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) + } + } else if(visibleOwnProjects.isEmpty()) { + Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { + Text("No projects matching this filter.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) + } + } else { + LazyVerticalGrid(GridCells.Adaptive(minSize = 250.dp)) { + items(visibleOwnProjects) { + ProjectChip(it) { onSelectProject(it.id) } + } + } + } + } + } + + Surface( + Modifier.padding(5.dp).weight(0.5f), + shape = MaterialTheme.shapes.medium, + tonalElevation = 5.dp + ) { + Column(Modifier.padding(15.dp).fillMaxSize()) { + Row(Modifier.height(leftHeaderHeight), verticalAlignment = Alignment.CenterVertically) { + Text("Publicly accessible projects: ", style = MaterialTheme.typography.headlineSmall) + } + + Spacer(Modifier.height(5.dp)) + Row(Modifier.height(IntrinsicSize.Min).onGloballyPositioned { rightFilterHeight = with(density) { it.size.height.toDp() } }) { + OutlinedTextField(publicFilter, { publicFilter = it }, + Modifier.weight(0.5f).fillMaxHeight(), label = { Text("Filter by name...") }) + Spacer(Modifier.width(5.dp)) + Box(Modifier.weight(0.5f)) { + var isOpen by remember { mutableStateOf(false) } + Surface(Modifier.fillMaxSize(), tonalElevation = 5.dp, shape = MaterialTheme.shapes.medium) { + Row(Modifier.fillMaxSize().clickable { isOpen = true }.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + publicUserFilter.toSet().forEach { user -> + QuickUser(user) { publicUserFilter.remove(user) } + } + + if(publicUserFilter.isEmpty()) { + Text("Filter by user...", fontStyle = FontStyle.Italic) + } + } + } + + DropdownMenu(isOpen, { isOpen = false }) { + DropdownMenuItem( + text = { OutlinedTextField(publicUserFilterStr, { publicUserFilterStr = it }, Modifier.fillMaxWidth(), label = { Text("Filter by username...") }) }, + onClick = {} + ) + + possibleUsers.forEach { + DropdownMenuItem( + text = { Text(it.name) }, + onClick = { publicUserFilter.toggle(it) } + ) + } + } + } + } + Spacer(Modifier.height(5.dp)) + + if (publicProjects.isEmpty()) { + Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { + Text("No projects yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) + } + } else if (visiblePublicProjects.isEmpty()) { + Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { + Text("No projects matching this filter.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) + } + } else { + LazyVerticalGrid(GridCells.Adaptive(minSize = 250.dp)) { + items(visiblePublicProjects) { + ProjectChip(it) { onSelectProject(it.id) } + } + } + } + } + } + } + + if (isAdmin) { + val users by vm.users + val invites by vm.invites + var editing by remember { mutableStateOf(null) } + var deleting by remember { mutableStateOf(null) } + Row(Modifier.weight(0.4f).padding(15.dp)) { + Surface( + Modifier.padding(5.dp).weight(0.5f), + shape = MaterialTheme.shapes.medium, + tonalElevation = 5.dp + ) { + Column(Modifier.padding(15.dp).fillMaxSize()) { + Text("Users", style = MaterialTheme.typography.headlineSmall) + + LazyColumn { + items(users) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(it.name, Modifier.weight(0.25f)) + Text(if (it.isAdmin) "Admin" else "Regular user", Modifier.weight(0.25f)) + Text( + "${it.usedProjects}/${if (it.projectLimit == -1) "∞" else it.projectLimit.toString()} projects", + Modifier.weight(0.25f) + ) + Row { + IconButton({ editing = it }) { + Icon(Lucide.Pencil, "Edit") + } + IconButton({ deleting = it }) { + Icon(Lucide.Trash, "Delete") + } + } + } + } + } + } + } + + Surface( + Modifier.padding(5.dp).weight(0.5f), + shape = MaterialTheme.shapes.medium, + tonalElevation = 5.dp + ) { + Column(Modifier.padding(15.dp).fillMaxSize()) { + val clip = LocalClipboard.current + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Invite codes", + Modifier.weight(1f), + style = MaterialTheme.typography.headlineSmall + ) + IconButton({ vm.mkInvite(clip, false) }) { + Icon(Lucide.Plus, "Create invite") + } + IconButton({ vm.mkInvite(clip, true) }) { + Icon(Lucide.ShieldPlus, "Create admin invite") + } + } + + if (invites.isEmpty()) { + Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { + Text( + "No open invites.", + Modifier.align(Alignment.Center), + fontStyle = FontStyle.Italic + ) + } + } + + LazyColumn { + items(invites) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(it.code.toString(), Modifier.weight(0.25f)) + if (it.expires.isPast()) { + Text("Expired (${it.expires.fmt()})", Modifier.weight(0.25f)) + } else { + Text("Valid (expires ${it.expires.fmt()})", Modifier.weight(0.25f)) + } + Text(if (it.asAdmin) "Admin invite" else "", Modifier.weight(0.25f)) + + IconButton({ vm.deleteInvite(it.code) }) { + Icon(Lucide.Trash, "Delete") + } + } + } + } + } + } + } + + editing?.let { + EditUserDialog(it, { editing = null }) { isAdmin, projectLimit -> + vm.updateUser(it.id, isAdmin, projectLimit) + } + } + + deleting?.let { + ConfirmDeleteUserDialog(it, { deleting = null }) { + vm.deleteUser(it.id) + } + } + } + } + + if (creatingProject) CreateProjectDialog({ creatingProject = false }, vm::mkProject) +} + +@Composable +fun QuickUser(user: NamedID, onClickCross: () -> Unit) { + Surface(tonalElevation = 10.dp, color = MaterialTheme.colorScheme.surfaceVariant, shadowElevation = 1.dp, shape = MaterialTheme.shapes.medium) { + Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(user.name) + Spacer(Modifier.width(5.dp)) + IconButton(onClickCross) { + Icon(Lucide.X, "Remove") + } + } + } +} + +@Composable +fun EditUserDialog(user: UserListResponse.UserData, onCancel: () -> Unit, onUpdate: (isAdmin: Boolean?, projectLimit: Int?) -> Unit) { + Dialog(onDismissRequest = onCancel) { + var isAdmin by remember { mutableStateOf(user.isAdmin) } + var projectLimit by remember { mutableStateOf(user.projectLimit) } + Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) { + Text("Editing user ${user.name}", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(isAdmin, { isAdmin = it }) + Text(if(isAdmin) "Admin" else "Regular user") + } + Spacer(Modifier.height(10.dp)) + OutlinedTextField(if(projectLimit == -1) "∞" else projectLimit.toString(), + { projectLimit = it.toIntOrNull() ?: 0 }, Modifier.fillMaxWidth(), + label = { Text("Project limit") }, enabled = projectLimit != -1) + Row(Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically) { + Checkbox(projectLimit == -1, { projectLimit = if(projectLimit == -1) 1 else -1 }) + Text("No project limit") + } + Spacer(Modifier.height(10.dp)) + + CancelConfirmXRow(onCancel, { + onUpdate(isAdmin nonEq user.isAdmin, projectLimit nonEq user.projectLimit) + }, confirmText = "Save") + } + } + } +} + +@Composable +fun ConfirmDeleteUserDialog(user: UserListResponse.UserData, onCancel: () -> Unit, onDelete: () -> Unit) { + Dialog(onDismissRequest = onCancel) { + Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) { + Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(10.dp)) + Text("Are you sure you want to delete ${user.name}?") + Spacer(Modifier.height(10.dp)) + CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete") + } + } + } +} + +@Composable +fun CreateProjectDialog(onCancel: () -> Unit, onCreate: (name: String, isPublic: Boolean) -> Unit) { + Dialog(onDismissRequest = onCancel) { + var name by remember { mutableStateOf("") } + var isPublic by remember { mutableStateOf(false) } + + Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) { + Text("Create project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) + OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") }) + Spacer(Modifier.height(10.dp)) + Row(Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(isPublic, { isPublic = it }) + Text("Public project") + } + } + Spacer(Modifier.height(10.dp)) + + CancelConfirmXRow(onCancel, { + onCreate(name.trim(), isPublic) + }, confirmText = "Create", canConfirm = name.trim().isNotBlank()) + } + } + } +} + +@Composable +fun ProjectChip(summary: HomeResponse.ProjectSummary, onOpen: () -> Unit) { + Surface(Modifier.padding(10.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) { + Column(Modifier.clickable(onClick = onOpen).padding(15.dp)) { + Text(summary.name, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(5.dp)) + Column(Modifier.padding(start = 15.dp)) { + Text("${if(summary.isPublic) "Public" else "Private"} project") + Text("By user ${summary.owner.name}") + } + } + } +} diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/NoFeedbackIndication.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/NoFeedbackIndication.kt new file mode 100644 index 0000000..88a4f62 --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/NoFeedbackIndication.kt @@ -0,0 +1,18 @@ +package com.jaytux.phoebench.clients.ui + +import androidx.compose.foundation.IndicationNodeFactory +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DrawModifierNode + +class NoFeedbackIndication : IndicationNodeFactory { + override fun create(interactionSource: InteractionSource): DelegatableNode = NoFeedbackNode(interactionSource) + override fun hashCode(): Int = -1 + override fun equals(other: Any?): Boolean = other is NoFeedbackIndication + + class NoFeedbackNode(private val interactionSource: InteractionSource) : Modifier.Node(), DrawModifierNode { + override fun ContentDrawScope.draw() = drawContent() + } +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt new file mode 100644 index 0000000..dd65330 --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt @@ -0,0 +1,529 @@ +package com.jaytux.phoebench.clients.ui + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.viewmodel.compose.viewModel +import com.composables.icons.lucide.* +import com.jaytux.phoebench.clients.darken +import com.jaytux.phoebench.clients.data.ProjectVM +import com.jaytux.phoebench.clients.data.mutableStateSetFrom +import com.jaytux.phoebench.clients.dualLerp +import com.jaytux.phoebench.clients.fmt +import com.jaytux.phoebench.clients.fmtRange +import com.jaytux.phoebench.clients.inRange +import com.jaytux.phoebench.clients.nonEq +import com.jaytux.phoebench.clients.randomColor +import com.jaytux.phoebench.common.Either +import com.jaytux.phoebench.common.TimeUnit +import com.jaytux.phoebench.common.asError +import com.jaytux.phoebench.common.asValue +import com.jaytux.phoebench.common.error +import com.jaytux.phoebench.common.isValue +import com.jaytux.phoebench.common.value +import com.kborowy.colorpicker.KolorPicker +import io.github.koalaplot.core.line.LinePlot +import io.github.koalaplot.core.style.KoalaPlotTheme +import io.github.koalaplot.core.style.LineStyle +import io.github.koalaplot.core.xygraph.AxisContent +import io.github.koalaplot.core.xygraph.DefaultPoint +import io.github.koalaplot.core.xygraph.XYGraph +import io.github.koalaplot.core.xygraph.autoScaleRange +import io.github.koalaplot.core.xygraph.autoScaleXRange +import io.github.koalaplot.core.xygraph.autoScaleYRange +import io.github.koalaplot.core.xygraph.rememberAxisStyle +import io.github.koalaplot.core.xygraph.rememberFloatLinearAxisModel +import io.github.koalaplot.core.xygraph.rememberGridStyle +import kotlin.time.Instant +import kotlin.uuid.Uuid + +@Composable +fun ProjectView(id: Uuid, forceBack: () -> Unit) { + val vm = viewModel(key = id.toString()) { ProjectVM(id, _forceBack = forceBack) } + + val name by vm.name + val owner by vm.owner + val public by vm.public + val editable by vm.editable + val labels by vm.labels + + var editing by remember { mutableStateOf(false) } + var deleting by remember { mutableStateOf(false) } + var addOpen by remember { mutableStateOf(false) } + var addingLabel by remember { mutableStateOf(false) } + var addingData by remember { mutableStateOf(false) } + + Column(Modifier.padding(all = 15.dp)) { + Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) { + Text("Project ${name ?: "Unnamed Project"}", style = MaterialTheme.typography.headlineMedium) + if(editable) { + Spacer(Modifier.width(25.dp)) + IconButton({ editing = true }) { + Icon(Lucide.Pencil, "Edit project details") + } + IconButton({ deleting = true }) { + Icon(Lucide.Trash, "Delete project") + } + } + } + owner?.let { Text("${if(public) "Public" else "Private"} project by user $it") } + Spacer(Modifier.height(15.dp)) + Surface(Modifier.fillMaxSize().padding(8.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) { + Box(Modifier.fillMaxSize().padding(15.dp)) { + ProjectPlotArea(vm) + + if(addOpen) { + CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) { + Box(Modifier.clickable { addOpen = false }.fillMaxSize()) + } + } + + Column(Modifier.align(Alignment.BottomEnd).padding(25.dp), horizontalAlignment = Alignment.End) { + if(addOpen) { + var lblWidth by mutableStateOf(1000.dp) + var dataWidth by mutableStateOf(1000.dp) + val density = LocalDensity.current + + Column(Modifier.width(maxOf(lblWidth, dataWidth)), horizontalAlignment = Alignment.End) { + Surface(Modifier.onGloballyPositioned { + lblWidth = with(density) { it.size.width.toDp() } + }, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) { + Row(Modifier.clickable { addingLabel = true }.padding(15.dp)) { + Icon(Lucide.CaseSensitive, "Add label") + Spacer(Modifier.width(5.dp)) + Text("Label") + } + } + + Spacer(Modifier.height(10.dp)) + + Surface(Modifier.onGloballyPositioned { + dataWidth = with(density) { it.size.width.toDp() } + }, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) { + Row(Modifier.clickable { addingData = true }.padding(15.dp)) { + Icon(Lucide.ChartLine, "Add data") + Spacer(Modifier.width(5.dp)) + Text("Data") + } + } + + Spacer(Modifier.height(15.dp)) + } + } + + IconButton({ addOpen = !addOpen }, Modifier.scale(1.5f), shape = MaterialTheme.shapes.medium) { + Icon(if(addOpen) Lucide.X else Lucide.Plus, if(addOpen) "Add data" else "Close menu") + } + } + } + } + } + + if(editing) ProjectEditDialog(name ?: "Unnamed Project", public, { editing = false }) { name, public -> + vm.update(name, public) + } + + if(deleting) ConfirmDeleteProjectDialog(name ?: "Unnamed Project", { deleting = false }) { + vm.delete() + } + + if(addingLabel) AddLabelDialog({ addingLabel = false; addOpen = false }) { name, color -> + vm.mkLabel(name, color) + addOpen = false + } + + if(addingData) AddEntryDialog(labels, + onCancel = { addingData = false; addOpen = false }, + onAddLbl = { name, lbl -> vm.mkLabel(name, lbl) } + ) { label, warmups, measurements, unit -> + vm.mkEntry(label, warmups, measurements, unit) + addOpen = false + } +} + +@Composable +fun ProjectEditDialog(name: String, public: Boolean, onCancel: () -> Unit, onSave: (name: String?, public: Boolean?) -> Unit) { + Dialog(onDismissRequest = onCancel) { + var updName by remember { mutableStateOf(name) } + var isPublic by remember { mutableStateOf(public) } + + Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) { + Text("Create project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) + OutlinedTextField(updName, { updName = it }, Modifier.fillMaxWidth(), label = { Text("Name") }) + Spacer(Modifier.height(10.dp)) + Row(Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(isPublic, { isPublic = it }) + Text("Public project") + } + } + Spacer(Modifier.height(10.dp)) + + CancelConfirmXRow(onCancel, { + onSave(updName.trim() nonEq name, isPublic nonEq public) + }, confirmText = "Save", canConfirm = updName.trim().isNotBlank()) + } + } + } +} + +@Composable +fun ConfirmDeleteProjectDialog(name: String, onCancel: () -> Unit, onDelete: () -> Unit) { + Dialog(onDismissRequest = onCancel) { + Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) { + Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(10.dp)) + Text("Are you sure you want to delete ${name}?") + Spacer(Modifier.height(10.dp)) + CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete") + } + } + } +} + +@Composable +fun AddLabelDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color) -> Unit) { + Dialog(onDismissRequest = onCancel) { + var name by remember { mutableStateOf("") } + var color by remember { mutableStateOf(randomColor()) } + + Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) { + Text("Create label", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) + OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") }) + Spacer(Modifier.height(10.dp)) + KolorPicker(color, { color = it }, alphaTrackVisible = false, modifier = Modifier.aspectRatio(1f)) + Spacer(Modifier.height(10.dp)) + + CancelConfirmXRow(onCancel, { + onAdd(name.trim(), color) + }, confirmText = "Create", canConfirm = name.trim().isNotBlank()) + } + } + } +} + +@Composable +fun QuickLabel(lbl: ProjectVM.Label) { + Row(Modifier.height(IntrinsicSize.Min).padding(vertical = 3.dp), verticalAlignment = Alignment.Bottom) { + Box(Modifier.fillMaxHeight().aspectRatio(1f).background(lbl.uiColor)) {} + Spacer(Modifier.width(15.dp)) + Text(lbl.name) + Text(" ${lbl.colorStr}", style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(alpha = 0.75f)) + } +} + +@Composable +fun AddEntryDialog( + labels: Map, + onCancel: () -> Unit, onAddLbl: (name: String, color: Color) -> Unit, + onAdd: (label: ProjectVM.Label, warmups: List, measurements: List, unit: TimeUnit) -> Unit +) { + Dialog(onDismissRequest = onCancel) { + var label by remember { mutableStateOf(null) } + var warmups by remember { mutableStateOf("") } + var measurements by remember { mutableStateOf("") } + var unit by remember { mutableStateOf(TimeUnit.SECONDS) } + + var addingLabel by remember { mutableStateOf(false) } + var warmupParsed by remember { mutableStateOf>>(listOf().value()) } + var measureParsed by remember { mutableStateOf>>(listOf().value()) } + + fun parse(text: String): Either> { + val parts = text.split(',') + val parsed = ArrayList(parts.size) + val invalids = mutableListOf() + parts.forEach { + val trim = it.trim() + if(trim.isEmpty()) return@forEach + trim.toFloatOrNull()?.let { f -> parsed += f } ?: run { invalids += trim } + } + return if(invalids.isEmpty()) parsed.value() + else "Invalid elements: ${invalids.joinToString(", ") { "'$it'" }}".error() + } + + LaunchedEffect(warmups) { warmupParsed = parse(warmups) } + + LaunchedEffect(measurements) { measureParsed = parse(measurements) } + + Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) { + Text("Create data entry", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(5.dp)) + var dropDownOpen by remember { mutableStateOf(false) } + Box { + Surface( + Modifier.fillMaxWidth().padding(8.dp), + shape = MaterialTheme.shapes.small, + tonalElevation = 2.dp + ) { + Box(Modifier.fillMaxWidth().clickable { dropDownOpen = true }.padding(8.dp)) { + label?.let { QuickLabel(it) } ?: Text("Select label...", fontStyle = FontStyle.Italic) + } + } + DropdownMenu(dropDownOpen, { dropDownOpen = false }) { + labels.values.forEach { lbl -> + DropdownMenuItem({ QuickLabel(lbl) }, { label = lbl; dropDownOpen = false }) + } + HorizontalDivider(Modifier.height(1.dp)) + + DropdownMenuItem({ + Text( + "Create new label...", + color = LocalContentColor.current.copy(alpha = 0.75f), + fontStyle = FontStyle.Italic + ) + }, { addingLabel = true }) + } + } + + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + TimeUnit.entries.forEachIndexed { idx, it -> + SegmentedButton(unit == it, { unit = it }, shape = SegmentedButtonDefaults.itemShape(idx, TimeUnit.entries.size)) { + Text(it.disp) + } + } + } + + Spacer(Modifier.height(7.dp)) + Text("Data (separate data points by commas)") + Spacer(Modifier.height(5.dp)) + OutlinedTextField(warmups, { warmups = it }, Modifier.fillMaxWidth(), + singleLine = false, minLines = 10, label = { Text("Warmup data") }) + warmupParsed.asError()?.let { + Text(it, color = MaterialTheme.colorScheme.error, fontStyle = FontStyle.Italic) + } + + Spacer(Modifier.height(5.dp)) + OutlinedTextField(measurements, { measurements = it }, Modifier.fillMaxWidth(), + singleLine = false, minLines = 10, label = { Text("(Steady-state) Measurements") }) + measureParsed.asError()?.let { + Text(it, color = MaterialTheme.colorScheme.error, fontStyle = FontStyle.Italic) + } + + Spacer(Modifier.height(5.dp)) + CancelConfirmXRow(onCancel, { + onAdd(label!!, warmupParsed.asValue()!!, measureParsed.asValue()!!, unit) + }, confirmText = "Create", canConfirm = label != null && warmupParsed.isValue() && measureParsed.isValue()) + } + } + + if(addingLabel) AddLabelDialog({ addingLabel = false }, onAddLbl) + } +} + +@Composable +fun ProjectPlotArea(vm: ProjectVM){ + val entries by vm.entries + val labels by vm.labels + val editable by vm.editable + + var displayWarmups by remember { mutableStateOf(false) } + var currentUnit by remember { mutableStateOf(TimeUnit.SECONDS) } + var renderableEntries by remember { mutableStateOf(listOf>, Color>>()) } + var xMax by remember { mutableStateOf(1f) } + var yMin by remember { mutableStateOf(0f) } + var yMax by remember { mutableStateOf(1f) } + + var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) } + var timeMax by remember { mutableStateOf(Instant.DISTANT_FUTURE) } + var timeFilter by remember { mutableStateOf(0f..1f) } + var timeFilterString by remember { mutableStateOf("") } + + val labelFilter = remember(labels) { mutableStateSetFrom(labels.values) } + val labelFilterKey by labelFilter.revision + + LaunchedEffect(entries, displayWarmups, currentUnit, timeFilter, timeMin, timeMax, labelFilterKey) { + var maxX = 0f + var minY = 0f + var maxY = 0f + val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive) + renderableEntries = entries.mapNotNull { entry -> + val use = if(displayWarmups) entry.warmups else entry.measurements + maxX = maxOf(maxX, use.size.toFloat()) + + if(entry.label !in labelFilter) return@mapNotNull null + if(!(entry.timeStamp inRange timeRange)) return@mapNotNull null + + use.mapIndexed { idx, it -> + val converted = entry.nativeUnit.convertTo(currentUnit, it) + minY = minOf(minY, converted) + maxY = maxOf(maxY, converted) + + DefaultPoint(idx.toFloat(), converted) + } to entry.label.uiColor + } + xMax = maxX + yMin = minY + yMax = maxY + } + + LaunchedEffect(entries) { + timeMin = entries.minOfOrNull { it.timeStamp } ?: Instant.DISTANT_PAST + timeMax = entries.maxOfOrNull { it.timeStamp } ?: Instant.DISTANT_FUTURE + } + + LaunchedEffect(timeMin, timeMax, timeFilter) { + timeFilterString = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive).fmtRange() + } + + Row(Modifier.fillMaxSize().padding(20.dp)) { + Box(Modifier.weight(0.66f).fillMaxHeight()) { + if(entries.isEmpty()) { + Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { + Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) + } + } + else { + Column { + Text("Measurements", style = MaterialTheme.typography.headlineSmall) + Row { + Row(Modifier.weight(0.66f), verticalAlignment = Alignment.CenterVertically) { + Text("View as: ", fontWeight = FontWeight.Bold) + Spacer(Modifier.width(5.dp)) + SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { + TimeUnit.entries.forEachIndexed { idx, it -> + SegmentedButton( + currentUnit == it, + { currentUnit = it }, + shape = SegmentedButtonDefaults.itemShape(idx, TimeUnit.entries.size) + ) { + Text(it.disp) + } + } + } + } + + Spacer(Modifier.width(50.dp)) + + Row(Modifier.weight(0.33f), verticalAlignment = Alignment.CenterVertically) { + Text("Display: ", fontWeight = FontWeight.Bold) + Spacer(Modifier.width(5.dp)) + SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { + SegmentedButton(!displayWarmups, { displayWarmups = false }, + shape = SegmentedButtonDefaults.itemShape(0, 2) + ) { + Text("(Steady-state) measurements") + } + + SegmentedButton(displayWarmups, { displayWarmups = true }, + shape = SegmentedButtonDefaults.itemShape(1, 2) + ) { + Text("Warmup measurements") + } + } + } + } + Spacer(Modifier.height(5.dp)) + Column(Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Filter by timestamp: ", fontWeight = FontWeight.Bold) + Spacer(Modifier.width(5.dp)) + RangeSlider(timeFilter, { timeFilter = it }, Modifier.fillMaxWidth(), valueRange = 0f..1f) + } + Text(timeFilterString, Modifier.align(Alignment.End)) + } + Spacer(Modifier.height(15.dp)) + + val style = rememberAxisStyle() + val lineColor = SolidColor(Color.LightGray.copy(alpha = 0.25f)) + val gridStyle = rememberGridStyle( + horizontalMajorStyle = KoalaPlotTheme.axis.majorGridlineStyle?.copy(brush = lineColor), + horizontalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor), + verticalMajorStyle = KoalaPlotTheme.axis.majorGridlineStyle?.copy(brush = lineColor), + verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor) + ) + XYGraph( + rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()), + rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()), + xAxisContent = AxisContent( + labels = { AxisLabel(it.fmt()) }, + title = {}, + style = style + ), + yAxisContent = AxisContent( + labels = { AxisLabel(it.fmt()) }, + title = {}, + style = style + ), + modifier = Modifier.weight(1f), + gridStyle = gridStyle + ) { + renderableEntries.forEach { (data, color) -> + LinePlot(data, lineStyle = LineStyle(SolidColor(color), strokeWidth = 3.dp)) + } + } + } + } + } + + Spacer(Modifier.width(20.dp)) + + Box(Modifier.weight(0.33f).fillMaxHeight()) { + Column { + Text("Labels", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(10.dp)) + if(labels.isEmpty()) { + Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { + Text("No labels yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) + } + } + else { + LazyColumn(Modifier.padding(start = 5.dp)) { + items(labels.toList()) { (_, lbl) -> + Box(Modifier.fillMaxWidth().clickable { labelFilter.toggle(lbl) }) { + Box { + QuickLabel(lbl) + + if(lbl !in labelFilter) { + Box(Modifier.matchParentSize()) { + HorizontalDivider( + Modifier.fillMaxWidth().align(Alignment.Center), + thickness = 3.dp, + color = LocalContentColor.current + ) + } + } + } + } + } + } + } + } + } + } +} + +@Composable +fun AxisLabel( + label: String, + modifier: Modifier = Modifier, +) { + Text( + label, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodySmall, + modifier = modifier, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) +} \ No newline at end of file diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/Widgets.kt b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/Widgets.kt new file mode 100644 index 0000000..7a63a4e --- /dev/null +++ b/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/Widgets.kt @@ -0,0 +1,33 @@ +package com.jaytux.phoebench.clients.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.jaytux.phoebench.clients.nonEq + +@Composable +fun CancelConfirmRow( + onCancel: () -> Unit, onConfirm: () -> Unit, confirmText: String = "Confirm", cancelText: String = "Cancel", + modifier: Modifier = Modifier, canConfirm: Boolean = true +) { + Row(modifier) { + Button(onCancel, Modifier.weight(0.5f)) { + Text(cancelText) + } + Spacer(Modifier.width(5.dp)) + Button(onConfirm, Modifier.weight(0.5f), enabled = canConfirm) { + Text(confirmText) + } + } +} + +@Composable +fun CancelConfirmXRow( + onCancel: () -> Unit, onConfirm: () -> Unit, confirmText: String = "Confirm", cancelText: String = "Cancel", + modifier: Modifier = Modifier, canConfirm: Boolean = true +) = CancelConfirmRow(onCancel, { onConfirm(); onCancel() }, confirmText, cancelText, modifier, canConfirm) \ No newline at end of file diff --git a/clients/src/desktopMain/kotlin/com/jaytux/phoebench/clients/Main.kt b/clients/src/desktopMain/kotlin/com/jaytux/phoebench/clients/Main.kt new file mode 100644 index 0000000..037c020 --- /dev/null +++ b/clients/src/desktopMain/kotlin/com/jaytux/phoebench/clients/Main.kt @@ -0,0 +1,10 @@ +package com.jaytux.phoebench.clients + +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application + +fun main() = application { + Window(onCloseRequest = ::exitApplication, title = "PhoeBench") { + App() + } +} \ No newline at end of file diff --git a/clients/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt b/clients/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt new file mode 100644 index 0000000..627f333 --- /dev/null +++ b/clients/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt @@ -0,0 +1,58 @@ +package com.jaytux.phoebench.clients + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.ClipEntry +import com.github.javakeyring.Keyring +import io.ktor.client.* +import io.ktor.client.engine.okhttp.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import java.awt.datatransfer.StringSelection +import kotlin.uuid.Uuid + +object JVMStore : IStore { + private val json = Json + const val SERVICE = "com.jaytux.phoebench" + + private class StoredProperty( + val key: String, + val toString: (T) -> String, val fromString: (String) -> T + ) : IStore.IStoredProperty { + constructor(key: String, serializer: KSerializer) : this(key, + { json.encodeToString(serializer, it) }, + { json.decodeFromString(serializer, it) } + ) + + override fun load(): T? = runCatching { + val keyring = Keyring.create() + keyring.getPassword(SERVICE, key) + }.getOrNull()?.let { fromString(it) } + + override fun save(value: T) = runCatching { + val keyring = Keyring.create() + keyring.setPassword(SERVICE, key, toString(value)) + }.onFailure { println("Failed to write to OS keyring: ${it.message}") }.ignore() + + override fun erase() = runCatching { + val keyring = Keyring.create() + keyring.deletePassword(SERVICE, key) + }.ignore() + } + + override fun refreshToken(): IStore.IStoredProperty = + StoredProperty("refresh_token", serializer()) + + override fun server(): IStore.IStoredProperty = + StoredProperty("server_url", {it}, {it}) +} + +actual fun persistentStore(): IStore = JVMStore + +actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(OkHttp) { + builder() +} + +@OptIn(ExperimentalComposeUiApi::class) +actual suspend fun String.toClipEntry(): ClipEntry = + ClipEntry(StringSelection(this)) \ No newline at end of file diff --git a/clients/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/Main.kt b/clients/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/Main.kt new file mode 100644 index 0000000..5b64c94 --- /dev/null +++ b/clients/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/Main.kt @@ -0,0 +1,12 @@ +package com.jaytux.phoebench.clients + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.ComposeViewport +import kotlinx.browser.document + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + ComposeViewport(document.body!!) { + App() + } +} \ No newline at end of file diff --git a/clients/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt b/clients/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt new file mode 100644 index 0000000..9832c1c --- /dev/null +++ b/clients/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt @@ -0,0 +1,49 @@ +package com.jaytux.phoebench.clients + +import androidx.compose.ui.platform.ClipEntry +import io.ktor.client.* +import io.ktor.client.engine.js.* +import kotlinx.browser.window +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import kotlin.uuid.Uuid + +object WasmJsStore : IStore { + private val json = Json + private class StoredProperty( + val key: String, + val toString: (T) -> String, val fromString: (String) -> T + ) : IStore.IStoredProperty { + constructor(key: String, serializer: KSerializer) : this(key, + { json.encodeToString(serializer, it) }, + { json.decodeFromString(serializer, it) } + ) + override fun load(): T? = runCatching { + window.localStorage.getItem(key) + }.getOrNull()?.let { fromString(it) } + override fun save(value: T) = runCatching { + window.localStorage.setItem(key, toString(value)) + }.ignore() + + override fun erase() = runCatching { + window.localStorage.removeItem(key) + }.ignore() + + } + + override fun refreshToken(): IStore.IStoredProperty = + StoredProperty("refresh_token", serializer()) + + override fun server(): IStore.IStoredProperty = + StoredProperty("server_url", {it}, {it}) +} + +actual fun persistentStore(): IStore = WasmJsStore + +actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(Js) { + builder() +} + +actual suspend fun String.toClipEntry(): ClipEntry = + ClipEntry.withPlainText(this) \ No newline at end of file diff --git a/clients/src/wasmJsMain/resources/index.html b/clients/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..1567ed2 --- /dev/null +++ b/clients/src/wasmJsMain/resources/index.html @@ -0,0 +1,12 @@ + + + + + + PhoeBench + + + + + + diff --git a/clients/src/wasmJsMain/resources/styles.css b/clients/src/wasmJsMain/resources/styles.css new file mode 100644 index 0000000..8e94d43 --- /dev/null +++ b/clients/src/wasmJsMain/resources/styles.css @@ -0,0 +1,7 @@ +html, body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} diff --git a/common/build.gradle.kts b/common/build.gradle.kts new file mode 100644 index 0000000..bfb6f95 --- /dev/null +++ b/common/build.gradle.kts @@ -0,0 +1,81 @@ +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.serialization) +} + +val partialsDirectory = layout.buildDirectory.dir("generated/sources/partials") +val versionDirectory = layout.buildDirectory.dir("generated/sources/version") +val requestsDirectory = layout.projectDirectory.dir("src/commonMain/kotlin/com/jaytux/phoebench/common/") + +val generatePartials = tasks.register("generatePartials") { + group = "generation" + description = "Generate Partial classes (requests with all-nullable fields)" + val scriptFile = project.file("partialize.main.kts") + val targets = fileTree(requestsDirectory) { + include("**/*.kt") + } + val lst = targets.map { it.absolutePath } + + inputs.file(scriptFile) + inputs.files(targets) + outputs.dir(partialsDirectory) + executable = "kotlin" + doFirst { + args(scriptFile.absolutePath, partialsDirectory.get().asFile.absolutePath, *lst.toTypedArray()) + } +} + +val generateVersion = tasks.register("protocolVersion") { + doFirst { + val outFile = versionDirectory.get().file("com/jaytux/phoebench/common/Version.kt").asFile + outFile.parentFile.mkdirs() + outFile.writeText(""" + package com.jaytux.phoebench.common + + object ProtocolVersion { + const val VERSION="${rootProject.version}" + } + """.trimIndent()) + } +} + +kotlin { + jvm("desktop") + wasmJs { + browser() + } + + sourceSets { + val commonMain by getting { + kotlin { + srcDir(generatePartials) + srcDir(versionDirectory) + } + dependencies { + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.auth) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization) + implementation(libs.kotlinx.serialization.json) + } + } + } + + targets.all { + compilations.all { + compileTaskProvider.configure { + dependsOn(generatePartials) + dependsOn(generateVersion) + } + } + } + + compilerOptions { + freeCompilerArgs.add("-Xcontext-parameters") + optIn.add("kotlin.uuid.ExperimentalUuidApi") + } +} \ No newline at end of file diff --git a/common/partialize.main.kts b/common/partialize.main.kts new file mode 100755 index 0000000..0dc25a2 --- /dev/null +++ b/common/partialize.main.kts @@ -0,0 +1,95 @@ +#!/usr/bin/env kotlin + +@file:DependsOn("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21") + +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironment +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironmentMode +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreProjectEnvironment +import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer +import org.jetbrains.kotlin.parsing.KotlinParserDefinition +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtPsiFactory +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.* +import kotlin.system.exitProcess + + +fun process(file: Path, outputDir: Path, factory: KtPsiFactory) { + if(!file.exists()) { + System.err.println("Skipping $file: file does not exist") + return + } + if(!file.isReadable()) { + System.err.println("Skipping $file: file is not readable") + return + } + + val ktFile = factory.createFile(file.readText()) + val pkg = ktFile.packageFqName.toString() + val imports = ktFile.importDirectives.mapNotNull { it.importedFqName }.filter { !it.toString().contains("ToPartialize") } + val classes = ktFile.declarations.mapNotNull { cls -> + if(cls !is KtClass || !cls.isData()) return@mapNotNull null + val annotations = cls.annotationEntries.map { it.text } + if("@ToPartialize" !in annotations) return@mapNotNull null + val ogName = cls.name ?: return@mapNotNull null + + val newName = "Partial$ogName" + + val nestedTypes = cls.declarations.filterIsInstance().mapNotNull { it.name }.toSet() + + val props = cls.primaryConstructorParameters.map { + val name = it.name + val type = it.typeReference?.text + if(name == null || type == null) return@mapNotNull null + name to type + }.joinToString(", ") { (n, t) -> + val nnT = if(t.endsWith('?')) t.substring(startIndex = 0, endIndex = t.length - 1) else t + val useT = if(nnT in nestedTypes) "$ogName.$nnT" else nnT + "val $n: $useT? = null" + } + + "@Serializable\ndata class $newName($props)" + } + + val cnt = "package $pkg\n\n${imports.joinToString("\n") { "import $it" }}\n\n${classes.joinToString("\n\n")}" +// println(cnt) + + val writeDir = outputDir.resolve(pkg.replace('.', '/')) + val fileName = file.fileName +// println("Trying to write to $writeDir/$fileName") + + if(!writeDir.exists()) writeDir.createDirectories() + Files.writeString(Path("$writeDir/$fileName"), cnt) +} + + +// usage kotlin partialize.main.kts output-dir [input-file]+ +if(args.isEmpty()) { + System.err.println("Usage: partialize.main.kts +") + exitProcess(-1) +} + +val outputDir = Path(args[0]) +if(outputDir.notExists()) { + println("Creating output directory $outputDir...") + Files.createDirectory(outputDir) +} +else if(!outputDir.isDirectory()) { + System.err.println("Output directory $outputDir is not a directory.") + exitProcess(-1) +} +else if(!outputDir.isWritable()) { + System.err.println("Cannot write to output directory $outputDir") + exitProcess(-1) +} + +val inputFiles = args.slice(1 until args.size) +val disp = Disposer.newDisposable() +val appEnv = KotlinCoreApplicationEnvironment.create(disp, KotlinCoreApplicationEnvironmentMode.Production) +appEnv.registerParserDefinition(KotlinParserDefinition()) +val project = KotlinCoreProjectEnvironment(disp, appEnv) +val factory = KtPsiFactory(project.project) +inputFiles.forEach { + process(Path(it), outputDir, factory) +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/ApiRoute.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/ApiRoute.kt new file mode 100644 index 0000000..af3043b --- /dev/null +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/ApiRoute.kt @@ -0,0 +1,239 @@ +package com.jaytux.phoebench.common + +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.request.url +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.Parameters +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.util.reflect.TypeInfo +import io.ktor.util.reflect.typeInfo +import kotlin.reflect.KClass +import kotlin.uuid.Uuid + +enum class Elevation { + UN_AUTH, AUTH, ADMIN +} + +sealed class ApiRoute(val verb: String, val path: String, val elevation: Elevation, private val _resType: TypeInfo) { + enum class ReqBodySource { + BODY, PARAMS, QUERY, NON_BODY, NON_QUERY + } + + open val pattern = path + open val bodySource = ReqBodySource.BODY + + suspend fun extract(body: HttpResponse, meta: Any? = null): Either = try { + println("Extracting response for $verb $pattern [${body.status.value}] as ${_resType.type.simpleName} (${_resType.kotlinType}${meta?.let { "; meta = $it" } ?: ""})") + if(body.status.isSuccess()) body.body(_resType).value() + else body.body().error() + } catch(e: Exception) { + ErrorResponse("Failed to parse response: ${e.message}").error() + } + + abstract suspend fun makeCall(client: IClient, body: TReq): HttpResponse + + suspend fun call(client: IClient, body: TReq, meta: Any? = null): Either = extract(makeCall(client, body), meta) + + open suspend fun parseParams(params: Parameters): TReq? = throw UnsupportedOperationException() + open suspend fun parseQuery(params: Parameters): TReq = throw UnsupportedOperationException() + open suspend fun parseNonBody(query: Parameters, params: Parameters): TReq? = throw UnsupportedOperationException() + open suspend fun parseNonQuery(params: Parameters, receiver: suspend (bodyType: KClass<*>, baseReq: Any) -> TReq?): TReq? = throw UnsupportedOperationException() + + class GetRoute(path: String, elevation: Elevation, resType: TypeInfo) + : ApiRoute("GET", path, elevation, resType) { + override suspend fun makeCall(client: IClient, body: EmptyRequest): HttpResponse = client.client.get { + url("${client.serverUrl}$path") + } + + suspend fun makeCall(client: IClient): HttpResponse = makeCall(client, EmptyRequest()) + } + + class GetRoute1( + path: String, elevation: Elevation, resType: TypeInfo, val urlEncode: (TReq) -> String, + val urlDecode: (String?) -> TReq? + ) : ApiRoute("GET", path, elevation, resType) { + override val pattern: String = "$path/{param}" + override val bodySource: ReqBodySource = ReqBodySource.PARAMS + + override suspend fun makeCall(client: IClient, body: TReq): HttpResponse = client.client.get { + url("${client.serverUrl}$path/${urlEncode(body)}") + } + + override suspend fun parseParams(params: Parameters): TReq? = urlDecode(params["param"]) + } + + class PostRoute(path: String, elevation: Elevation, private val _reqType: TypeInfo, resType: TypeInfo) + : ApiRoute("POST", path, elevation, resType) { + override suspend fun makeCall(client: IClient, body: TReq): HttpResponse = client.client.post { + url("${client.serverUrl}$path") + contentType(ContentType.Application.Json) + setBody(body, _reqType) + } + } + + class PostRoute1( + private val pathPre: String, private val pathPost: String, elevation: Elevation, private val _bodyType: TypeInfo, + resType: TypeInfo, val urlEncode: (TReq) -> String, val urlDecode: (String?) -> TReq? + ) : ApiRoute, TRes>("POST", "$pathPre/{param}/$pathPost", elevation, resType) { + override val pattern: String = "$pathPre/{param}/$pathPost" + override val bodySource: ReqBodySource = ReqBodySource.NON_QUERY + + override suspend fun makeCall(client: IClient, body: Pair): HttpResponse = client.client.post { + url("${client.serverUrl}$pathPre/${urlEncode(body.first)}/$pathPost") + contentType(ContentType.Application.Json) + setBody(body.second, _bodyType) + } + + override suspend fun parseNonQuery(params: Parameters, receiver: suspend (bodyType: KClass<*>, urlParam: Any) -> Pair?): Pair? { + val param = urlDecode(params["param"]) ?: return null + return receiver(_bodyType.type, param) + } + } + + class DeleteRoute1( + path: String, elevation: Elevation, resType: TypeInfo, val urlEncode: (TReq) -> String, + val urlDecode: (String?) -> TReq? + ) : ApiRoute("DELETE", path, elevation, resType) { + override val pattern: String = "$path/{param}" + override val bodySource: ReqBodySource = ReqBodySource.PARAMS + + override suspend fun makeCall(client: IClient, body: TReq): HttpResponse = client.client.delete { + url("${client.serverUrl}$path/${urlEncode(body)}") + } + + override suspend fun parseParams(params: Parameters): TReq? = urlDecode(params["param"]) + } + + class PatchRoute1( + path: String, elevation: Elevation, resType: TypeInfo, private val _bodyType: TypeInfo, + val urlEncode: (TReq) -> String, val urlDecode: (String?) -> TReq? + ) : ApiRoute, TRes>("PATCH", path, elevation, resType) { + override val bodySource: ReqBodySource = ReqBodySource.NON_QUERY + override val pattern: String = "$path/{param}" + + override suspend fun makeCall(client: IClient, body: Pair): HttpResponse = client.client.patch { + url("${client.serverUrl}$path/${urlEncode(body.first)}") + contentType(ContentType.Application.Json) + setBody(body.second, _bodyType) + } + + override suspend fun parseNonQuery(params: Parameters, receiver: suspend (bodyType: KClass<*>, urlParam: Any) -> Pair?): Pair? { + val param = urlDecode(params["param"]) ?: return null + return receiver(_bodyType.type, param) + } + } + + companion object { + fun parseUuid(str: String?) = str?.let { + try { + Uuid.parse(it) + } + catch(_: Exception) { + null + } + } + + /** + * Builds a GET route with no request parameters. + */ + inline fun get(path: String, elevation: Elevation) = + GetRoute(path, elevation, typeInfo()) + + /** + * Builds a GET route with one request parameter (encoded as URL parameter in the endpoint, like + * `/endpoint/arg`). + */ + inline fun get1(path: String, elevation: Elevation, + noinline encode: (TReq) -> String = { it.toString() }, noinline decode: (String?) -> TReq? + ) = GetRoute1(path, elevation, typeInfo(), encode, decode) + + /** + * Builds a GET route with one UUID request parameter (encoded as URL parameter in the endpoint, like + * `/endpoint/arg`). + */ + inline fun getUuid(path: String, elevation: Elevation) = + get1(path, elevation, Uuid::toString, this::parseUuid) + + /** + * Builds a POST route with request and response types. + */ + inline fun post(path: String, elevation: Elevation) = + PostRoute(path, elevation, typeInfo(), typeInfo()) + + /** + * Builds a POST route with two hardcoded path segments and one request parameter (encoded as URL + * parameter in the endpoint, like `/endpoint-pre/arg/endpoint-post`), and a request body. + */ + inline fun post1( + pathPre: String, pathPost: String, elevation: Elevation, noinline encode: (TReq) -> String, + noinline decode: (String?) -> TReq? + ) = PostRoute1(pathPre, pathPost, elevation, typeInfo(), typeInfo(), encode, decode) + + /** + * Builds a POST route with two hardcoded path segments and one UUID request parameter (encoded as URL + * parameter in the endpoint, like `/endpoint-pre/uuid/endpoint-post`), and a request body. + */ + inline fun postUuid(pathPre: String, pathPost: String, elevation: Elevation) = + post1(pathPre, pathPost, elevation, Uuid::toString, this::parseUuid) + + /** + * Builds a POST route with request type and no response (EmptyResponse). + */ + inline fun postNoRes(path: String, elevation: Elevation) = + post(path, elevation) + + /** + * Builds a DELETE route with one request parameter (encoded as URL parameter in the endpoint, like + * `/endpoint/arg`). + */ + inline fun delete1(path: String, elevation: Elevation, + noinline encode: (TReq) -> String = { it.toString() }, noinline decode: (String?) -> TReq? + ) = DeleteRoute1(path, elevation, typeInfo(), encode, decode) + + /** + * Builds a DELETE route with one UUID request parameter (encoded as URL parameter in the endpoint, like + * `/endpoint/arg`). + */ + inline fun deleteUuid(path: String, elevation: Elevation) = + delete1(path, elevation, Uuid::toString, this::parseUuid) + + /** + * Builds a DELETE route with one request parameter and no response (EmptyResponse). + */ + inline fun delete1NoRes(path: String, elevation: Elevation, + noinline encode: (TReq) -> String = { it.toString() }, noinline decode: (String?) -> TReq? + ) = delete1(path, elevation, encode, decode) + + /** + * Builds a DELETE route with one UUID request parameter and no response (EmptyResponse). + */ + fun deleteUuidNoRes(path: String, elevation: Elevation) = + delete1NoRes(path, elevation, Uuid::toString, this::parseUuid) + + /** + * Builds a PATCH route with one request parameter and a request body. + */ + inline fun patch1( + path: String, elevation: Elevation, noinline encode: (TReq) -> String, noinline decode: (String?) -> TReq? + ) = PatchRoute1(path, elevation, typeInfo(), typeInfo(), encode, decode) + + /** + * Builds a PATCH route with a UUID request parameter and a request body. + */ + inline fun patchUuid(path: String, elevation: Elevation) = + patch1(path, elevation, Uuid::toString, this::parseUuid) + + /** + * Builds a PATCH route with a UUID request parameter and a request body, but without response (EmptyResponse). + */ + inline fun patchUuidNoRes(path: String, elevation: Elevation) = + patchUuid(path, elevation) + } +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Auth.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Auth.kt new file mode 100644 index 0000000..b79cb27 --- /dev/null +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Auth.kt @@ -0,0 +1,5 @@ +package com.jaytux.phoebench.common + +object Auth { + const val JWT_CLAIM = "pb-user-id" +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Either.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Either.kt new file mode 100644 index 0000000..2d1e97f --- /dev/null +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Either.kt @@ -0,0 +1,27 @@ +package com.jaytux.phoebench.common + +sealed class Either { + class Error(val errorData: E) : Either() + class Value(val value: V) : Either() +} +fun E.error(): Either = Either.Error(this) +fun V.value(): Either = Either.Value(this) +inline fun Either.bind(f: (V) -> Either): Either = when(this) { + is Either.Error -> errorData.error() + is Either.Value -> f(value) +} +inline fun Either.map(f: (V) -> V2): Either = bind { f(it).value() } +inline fun Either.fold(fError: (E) -> R, fValue: (V) -> R) = when(this) { + is Either.Error -> fError(errorData) + is Either.Value -> fValue(value) +} +suspend inline fun Either.foldSuspend(fError: suspend (E) -> R, fValue: suspend (V) -> R) = when(this) { + is Either.Error -> fError(errorData) + is Either.Value -> fValue(value) +} + +inline fun Either.isError() = this is Either.Error +inline fun Either.isValue() = this is Either.Value + +inline fun Either.asError() = (this as? Either.Error)?.errorData +inline fun Either.asValue() = (this as? Either.Value)?.value \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/IClient.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/IClient.kt new file mode 100644 index 0000000..0b84492 --- /dev/null +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/IClient.kt @@ -0,0 +1,10 @@ +package com.jaytux.phoebench.common + +import io.ktor.client.HttpClient + +interface IClient { + val client: HttpClient + val serverUrl: String + + data class Default(override val client: HttpClient, override val serverUrl: String) : IClient +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Requests.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Requests.kt new file mode 100644 index 0000000..0d06281 --- /dev/null +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Requests.kt @@ -0,0 +1,39 @@ +package com.jaytux.phoebench.common + +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.Serializable +import kotlin.time.Instant +import kotlin.uuid.Uuid + +annotation class ToPartialize + +@Serializable +data class LoginRequest(val name: String, val pass: String) + +@Serializable +data class SignupRequest(val invite: Uuid, val name: String, val pass: String) + +@Serializable +class EmptyRequest + +@Serializable +data class RefreshRequest(val refreshToken: Uuid) + +@Serializable @ToPartialize +data class ProjectRequest(val name: String, var isPublic: Boolean) + +@Serializable @ToPartialize +data class LabelRequest(val name: String, val color: String, val projectId: Uuid) + +@Serializable @ToPartialize +data class EntryRequest(val label: Uuid, val timestamp: Instant, val projectId: Uuid, val warmups: List, + val measurements: List, val unit: TimeUnit) + +@Serializable +data class LogoutRequest(val refresh: Uuid) + +@Serializable +data class UserUpdateRequest(val projectLimit: Int? = null, val isAdmin: Boolean? = null) + +@Serializable +data class InviteRequest(val asAdmin: Boolean) \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Responses.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Responses.kt new file mode 100644 index 0000000..7142d6a --- /dev/null +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Responses.kt @@ -0,0 +1,61 @@ +package com.jaytux.phoebench.common + +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlin.time.Instant +import kotlin.uuid.Uuid + +@Serializable +data class ErrorResponse(val msg: String) + +@Serializable +class EmptyResponse + +@Serializable +data class TokenResponse(val access: String, val refresh: Uuid) + +@Serializable +data class UuidResponse(val uuid: Uuid) + +@Serializable +data class InviteListResponse(val uuids: List) { + @Serializable + data class Invite(val code: Uuid, val expires: Instant, val asAdmin: Boolean) +} + +@Serializable +data class UserListResponse(val users: List) { + @Serializable + data class UserData(val id: Uuid, val name: String, val isAdmin: Boolean, val projectLimit: Int, val usedProjects: Int) +} + +@Serializable +data class NamedID(val name: String, val id: Uuid) + +@Serializable +data class HomeResponse(val username: String, val isAdmin: Boolean, val projectLimit: Int, val ownProjects: List, val publicProjects: List) { + @Serializable + data class ProjectSummary(val id: Uuid, val name: String, val isPublic: Boolean, val owner: NamedID) +} + +@Serializable +data class ProjectResponse(val id: Uuid, val name: String, val owner: NamedID, val isPublic: Boolean, val isEditable: Boolean, + val usedLabels: List, val entries: List) + +@Serializable +data class LabelResponse(val id: Uuid, val name: String, val color: String) + +@Serializable +data class EntryResponse(val id: Uuid, val labelId: Uuid, val timestamp: Instant, val warmups: List, + val measurements: List, val unit: TimeUnit) + +@Serializable +data class HandshakeResponse(val version: String = ProtocolVersion.VERSION) { +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Routes.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Routes.kt new file mode 100644 index 0000000..af5d04e --- /dev/null +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Routes.kt @@ -0,0 +1,44 @@ +package com.jaytux.phoebench.common + +object Routes { + object Auth { + val login = ApiRoute.post("/login", Elevation.UN_AUTH) + val signup = ApiRoute.post("/signup", Elevation.UN_AUTH) + val logout = ApiRoute.post("/logout", Elevation.AUTH) + val logoutEverywhere = ApiRoute.post("/logout/all", Elevation.AUTH) + val refresh = ApiRoute.post("/refresh", Elevation.UN_AUTH) + object Invite { + val new = ApiRoute.post("/invite", Elevation.ADMIN) + val list = ApiRoute.get("/invite", Elevation.ADMIN) + val delete = ApiRoute.deleteUuidNoRes("/invite", Elevation.ADMIN) + } + + object User { + val list = ApiRoute.get("/user", Elevation.ADMIN) + val update = ApiRoute.patchUuidNoRes("/user", Elevation.ADMIN) + val delete = ApiRoute.deleteUuidNoRes("/user", Elevation.ADMIN) + } + } + + val handshake = ApiRoute.get("/", Elevation.UN_AUTH) + val home = ApiRoute.get("/home", Elevation.AUTH) + + object Project { + val new = ApiRoute.post("/project", Elevation.AUTH) + val get = ApiRoute.getUuid("/project", Elevation.AUTH) + val update = ApiRoute.patchUuidNoRes("/project", Elevation.AUTH) + val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH) + } + + object Label { + val new = ApiRoute.post("/label", Elevation.AUTH) + val update = ApiRoute.patchUuidNoRes("/label", Elevation.AUTH) + val delete = ApiRoute.deleteUuidNoRes("/label", Elevation.AUTH) + } + + object Entry { + val new = ApiRoute.post("/entry", Elevation.AUTH) + val update = ApiRoute.patchUuidNoRes("/entry", Elevation.AUTH) + val delete = ApiRoute.deleteUuidNoRes("/entry", Elevation.AUTH) + } +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/TimeUnit.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/TimeUnit.kt new file mode 100644 index 0000000..050c46e --- /dev/null +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/TimeUnit.kt @@ -0,0 +1,15 @@ +package com.jaytux.phoebench.common + +import kotlinx.serialization.Serializable + +@Serializable +enum class TimeUnit(val disp: String, val mulToSec: Float) { + NANOS("ns", 1e-9f), + MICROS("μs", 1e-6f), + MILLIS("ms", 1e-3f), + SECONDS("s", 1f), + MINUTES("min", 60f), + HOURS("h", 3600f); + + fun convertTo(other: TimeUnit, valueInThis: Float): Float = valueInThis * (mulToSec / other.mulToSec) +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..b5998ca --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +kotlin.code.style=official +kotlin.daemon.jvmargs=-Xmx2048m \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..e9990f0 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,91 @@ +[versions] +dotenv = "6.5.1" +serialization = "1.11.0" +slf4j = "2.0.18" +json = "20260522" +exposed = "1.3.1" +ksoup = "0.2.6" +sqlite = "3.53.2.0" +mariadb = "3.5.9" +androidx-lifecycle = "2.10.0" +compose-multiplatform = "1.11.1" +kotlin = "2.4.10" +kotlinx-coroutines = "1.11.0" +ktor = "3.5.1" +spring-sec = "7.1.0" +atomic = "0.33.0" +datetime = "0.8.0" +material = "1.9.0" +java-keystore = "1.0.4" +lucide = "2.2.1" +koala-plot = "0.12.0" +kolor-picker = "2.1.0" + +[libraries] +androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } + +compose-runtime = { group = "org.jetbrains.compose.runtime", name = "runtime", version.ref = "compose-multiplatform" } +compose-foundation = { group = "org.jetbrains.compose.foundation", name = "foundation", version.ref = "compose-multiplatform" } +compose-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "compose-multiplatform" } +compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material" } +compose-components-resources = { group = "org.jetbrains.compose.components", name = "components-resources", version.ref = "compose-multiplatform" } + +exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } +exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" } +exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } +exposed-migration = { module = "org.jetbrains.exposed:exposed-migration-core", version.ref = "exposed" } +exposed-migration-jdbc = { module = "org.jetbrains.exposed:exposed-migration-jdbc", version.ref = "exposed" } +exposed-kotlin-datetime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" } + +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } + +ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" } +ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } +ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } +ktor-server-openapi = { module = "io.ktor:ktor-server-openapi", version.ref = "ktor" } +ktor-server-auto-head-response = { module = "io.ktor:ktor-server-auto-head-response", version.ref = "ktor" } +ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } +ktor-server-config-yaml = { module = "io.ktor:ktor-server-config-yaml", version.ref = "ktor" } +ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" } +ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" } +ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref = "ktor" } +ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" } +ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } + +json = { module = "org.json:json", version.ref = "json" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } +kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } + +mariadb = { module = "org.mariadb.jdbc:mariadb-java-client", version.ref = "mariadb" } +sqlite = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite" } +dotenv = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" } +ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } +spring-security-core = { module = "org.springframework.security:spring-security-core", version.ref = "spring-sec" } + +compose-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "compose-multiplatform" } +kotlinx-atomic = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomic" } + +lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "lucide" } +koala = { module = "io.github.koalaplot:koalaplot-core", version.ref = "koala-plot" } +kolor = { module = "com.kborowy:kolor-picker", version.ref = "kolor-picker" } + +java-keystore = { module = "com.github.javakeyring:java-keyring", version.ref = "java-keystore" } + +[plugins] +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +ktor = { id = "io.ktor.plugin", version.ref = "ktor" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 zcmWIWW@h1HVBp|jU|?`$00AZt!N9=4$-uzi>l)&y>*?pF&&+_TFn6P!tpfuCgFOQS zg9x%hUq?SrH`m}0JzuxazGqJRcd6=Pt~!mh;~Y*{$O0N=#SJiX+c#Ny() z5$qKr$3_3K&)u^8>Y}1Wol5gvtvi)`3?mK+C~~UQC^!hYJYeYFGKue9-qCw4o5X*7bh3|A;nWZND75EF ze{tO&pM$4ELp+aZ?QzoE7j$%WLEORBp=SzDt`Gbewa2e(Z#3Wc6A!;?y*nx`vgcyI z`OlAOqD=XLAHqaSD`s~^?TI~T9ilUp>D^Il(L2wN?+$7CkSF^7;NMTL_ZC&mm$~=; zIQyR+3l@p+pZLihTEoG=>W4v)mYd0SNh=azMF1x$WOy zUtFRnpmXEiwR@K@N#^CBKC#ko^P9JCbKai}5PufHZ8kNh?}+j@vBpIKf9n|IS6e#d ziazwXb7O9gtnnJTzY$j^xXt=oRkAaPzRmopRvFNY&t%(_rRbqd= zJoRenM%lEUx@j(F7b<4HVOp=zGS~IpYLDPs=OzAn-d6c9r2mh3XN=IdZ^nPyUiUSv zH?;V5z`>5kMe9j!LEem_bfZUG7urPC#jL(GEmeCpkLZS zCJo(Fie>YAZ~vOvll+7;E%i>`X46&bLb=Btlo?CPXKdGD@0fXa+r9dv>2{A)_BXBP zUc>7vdygr2yI=O@uSqM*W_zx_U+}rc@1SF}uBef`jmwsq%O3{@%U{^Qe^+WAQ?xVF zw6?_wZY;~Kos5A7?Q4%VAmsfZXl3%ed#-jU=x zrJ#%Tx}~E_mXZ25)yHKjX(?iw@jpxhwbp*%y;R}5Vv$dR_iDNR*<}R>7ECs5Gq|CD z{w~KyF1}e|2ECpWSnVw`W^PQdufA+(o5(U<-&ZGo?&S8^<9*V9x2c&J2Cvc%cWG7A za-FKgG;0N0@XBw$*kgn@7laCKHZ^oVlj_!0*|p;0ks8k!h0o1OS?ek_mY!=}(>vK} z(}Ihe8Yi=wNSXS7Kjwd=QewB7)X_-+e@k`+cg_^sb#ZfhoV!c-vMatv4~s}%6V|;J zV3c;`?Mk21C#Lom8yHR5GGmT6cUH_o<4u3Wt=CrWx>xb-+?rg!!i0r#MsMDf8RjpF z6)6i=z59lBo9|++bk9WA%ys8ybD#MXq^z&tx!X0Y$3Y{W#c;*!UA&jske1iE;Eq0C%#SEDcJPK;p?G~L*vw|raaH`Gb|O+XnQQ&Xz^sK%RH?+ zT<>;13vH0K?Y50Q%f95Gb4uOiR|$5mPkYzS>Wr$Gdg}NVgR^s3uItMAnKOGC7cYC@ zvgvu;Jkd>Wu05>{+}ty9gJwH|+iC1FSGZqGP>*(UJB`jZb2DbF{2r`xH&fNACA)lRo+5HCM#uQN z-}Wi(vx~i}XFNS)Hdko=aZUY&pEn(RCAsa)bXfxC|y|0(v{#!a04c4K(dqi^dijQzJ|KHqS0$|A+XFPV1g`Buz0 z>vOTuNvn6;*)NOFoDA_i_1=l~>a-mjxP41h{JS}ir|JKiGS%kt=4rnDlizbo&8zFI zj=f#>#OBnYWtB>nhfe#wNcdZKhvVmb^#*=Zj%0C`vSKsl)H&s;XHCz2PdTe?aeuby z`j+|k_iQhIqqub9{-*s$c^A+4*l_sL>5WGe**4B!{LJV9!@M*1gT6de*tsKe&ho(2 zCALOwi#~K2{wOQdxM$lpX?z`u7Av?`yP2~yw&EKQu_TTQ&c&Zaj?dGm2i)u zr7H3g?xN*vv%0dop5A*U74OWod6w5PgWaAh13h#lkIQU2cJ7^nDK8ZAE`4KbTi^71lEu+T)AD&~U7YK(|5%+~-|Ayg z{&nS_)lK>Hn|k{fha8kW=-28M`cur;`p)wd=78G*8s3S&@6LD{p`&gc^kPC<|H9xy z5mQ7k2Qf$oz2xNY*v?vRzVh!zmwC&Fem(53}Ed#zUNL}X*4?BEyT?g)LqhTEYuwoyrawOvzJkEaPCe#ja*Gk z`w9|^5_3~aQj2u+5_40Fk!(e8pCh+~`BB@d$vK%AP1~z??AO^aF)#$PGB6loDtAmO zE-6Y(E^*G!%}vZp@yX0fbQ(s z|78L~moiKg*9u-bay&8edTzpW9_PjNhwd-ZH@SF3{z1RdJ=qBX)3$s`e16Ze`u)!0 zXZP;f*Vq4J3vj#Xz+`Pz#_O^n+2Q!-1J!~Rk+PPum9ngD6<5E;L?(Bn6))$xHSzvN z$uQ}4>GJ&5xzSrHc6)oQdKkT@`c$j*8}$Wh_Pd$ek`9aeofO=2d8>EWb=|ACnKgH) z_Wz0Nub$iR=EV1Nxr(`gi8pqf`MUN0{Pi`$i?UXSt`wY|*DKg|IOlQC{LewttNmWe zwz905D*0wfZCdoEN!-p$TDjY|tkGSxAbZl6Thk8O>YV8PzEk2xQ(|`9finm1ua@YF zD>?Ow=h~D*UuBl+dPzz4T#wh;tsi?|`o)dp9al?a`%f=ipluy7t9M0F*E{E}r4nI* z+|J=0oTUddemN)J%)hgsf9C36+vKkpCO$B8{8|0t36t-X6!4w>^@Tv@Zf~e=F za(zY3O+8lI-6|RHns1W2c{aYIW{<-ci+CBw)8gF9N4~jr)Xxu#P~<-4oiO*C^s1n` zIo2yhDqeqS(|m7dE7;}th*2&mub^+sqZ@PPB+LBIkorI4(fkSP1wV%=QHu^n6dPS&+c-xn{@W1jcevokw!&_D@#- z=DPI5+gZtaPd1+5~-ekRfOMTfx6-hQOx7Zg2OP|c# zcGN(kGp%e(t#06zHBL&eEtiEVT%0I&ad+GDnXby`4X&k~zbd;qZDaKIwYr-F$~ZoS zJTbW;d?C#AxN6KD!Gn_`jD7Y+%vi>SrG7HVpo^9RsPId8Gowh&qqT#zIdCle4cVCn8cGF_%?>=(dBGOrH z4(G0{5LM>+yZr5{3f{+BY6g0by+!jC)E{e$_B%>Vu1Oc()njviAw&L3jfCj7l}YTo zwYqKuCmvk3XW!aIE$by09BN#B$ayCJ?Duvr6#pPt`j_B--o?u3j(3#r z*;2Dl_KrC#1=>Q?w?%)EQjdA-GpUMmul%C?N%Bq;C%eu!jE!sAdse1RZ?oXng=(!; zW*_9d*F~A}*?in~DOfwQDWmSbRH@$VuzMB}Hv~+x*bOdY_47y;R#ldm{<`N-2GzsS9Ri2{*o+f1qwjEB|5>Hdf;v!wTqaLhtFS?2y}}L= z^`LbNvj6_LBA_DW&iO*3RzI=J?+M4T)z?;W9Legc>v{5P#a|bX*&m-d2L1VR|HT)D zvpXg1w>Q1xnxg#0Kqub1>C^{VvCP88hn6+lmk4TJiFUl7qagD`j=50jcfY}dzj=bKQp_K= z>ztQ)lD_m_&gy!R?eh8dGpX&T0uAno>^t{R_m}WXW$)7W#pg;N-4O_!`0q$V&&~Z4 zCOk~upZtUW)-Fwkz@~?`4=+Uj{ajKpDNLM|VSm9QBiU~I;|Z(})zev`S2kL0d$~H& z_u;k~&;PNa7Fe}w&sDlJGB9{EVXZrniYLdS^wQkayb{-nDp&i}p?6zC+3$pLDRCWoaA?}Q%b7FW(u7W)S+V}MVU`Dc+Tj)aG}$mK+jp5{_SWfm>A8{S3F#yVz3} z%aU>w#I?>Osa;zAyuo%!OqX7x^k$WX3zz;Ya=Z}t=@>PGvRAxirl9JME zmn;3=`|UPnt=#xs+iKdHTUVx9M_gTgY>n6J>}ih{^pu`8SaWJl>EiS!U*B!BJ|6s} z?$EcA%lleo&zur7DCaX?u4T67sPFCA-z=!Hvibg_Uy_Ur4Czb^44{@2Xt)s4dea9r zfRJJZttHbN=<9vhL7?{XwPRP-uTnp+WWwcUW42VmHIPF?rQ-gw*PB%Do+u0cb=&3# z^Pj*+X01X#T(J>l&(7}q-Zpdo{q^|_-z~~7looguO!Cp)Vb)ToKW7u`=ip^6|C4s4 zJ)Xj@`li(8X(#6%EteNIdcyxQQ7HZk@kRXxD#T4)fcc{83Mg*PTfV z&ShHIcr|kyhwql0ZRwk*9lc$UbLBu?U}3hFY_#QpM8WkFm1V@=pOi-p-{3T^t4wSR z4C#Cf3~HFwG*bBbBPI2@VbSHnp%Ql|FPm#JGp=Jx=E9gKN|{d5TemD&dnZcoXxxQs zVqF}UQ+(K$%`})L`gHS^Ar)9rIU z&n@1!dH?Nye$T$w*ZpHXaQcT@Pp*af;~ELZIoYoA)1nF)^SNIxlDOCTKtRv+1D9U6 z4E7EKYVF6mgEbvrmo9+s&~}*U{|r zw!CLtLRQPxt+;+QK z;fkeXjn zOeX3##!H`Xi9Fu%INxINQ(fJ) zx1!vfPS2Qk`R!q$q{8_>J)<{YNp+Tf8$Urn#@u+8hiGD2*Xq-=O!d#T98QZe6$|ke z&o1A5wB#`7Ep02~J+E*2g+4pJS$E;?OAMD^ORapK-TZ3H*_&CvUTv9GzV4L9sU3QX zI}=$ZHWq|!PQR|XS@T}J^Q^sBt9j>iu36EVtQW1qb3A*+rTr6gWLND}f3aoZ>gq?* zLBTb3Mo-F)**{$V>e<%{?UT2XGFulNJW-YH*DqeGyf|`^LYCe7r$(2f7A{DOJRP!U z;isUrf`NV0gqVI;l*%rd%NJf@5!AhFBaf@(Ys13^`9Fj&h`djKo%^wGndH)W<|gyI z7F}*EiZ8T$`8uXG+<&Tc`HpG&(ph&i_H>MMuamg%CanLU`C)VM9TT2iN*Wq_9#(J6io5>7=KqfE zA73wE4Lqo|!7he%hrGa%PuHq{C=1KXeELjAbk65R->97qL!!w1Zo*@Al5Fh0L}uj)nc&%e?r@OO>UbS$jNs=YB~P5!~bQe|C%hA2BH= znM`ZG3-=RGaXr6uj$>)_l-&XqrzXrw`Et@i<#RVjz2F*=5356@j?R!y>(VaV8~Ni` z^Wq6bo3zZnTg6TJFgy0P>V)e%$|c-mCYW2WTb%A(;?diXS(I?-T$#PTy3C4qlV*vx zy_vR3rtQYf$D+?yBtMgIPfTmEc2b|x;dbpUOHCQu%d%6?i%x7+epq4Kv#mg zOES6kS@+P%b94JI?4BVbkZ1d$r+l+gf{kO78;7*22fwBYkGhvaTyItEVW(LttC{*r zjxBuR*nX)o!}xss@hPuQ2-?e9We3^#J~rlkpG`$YcS36` zm!3-&axX+J>n~Gxxqdmpntgc>P4D zY5iV?=eN)Jy*!{U@`&G|>d1SGwmk6Y{?CM3i~M=pwSFlB1H)4W1_lkhwMal>QE_UK ziXmjUp|!`6>yUwf>;H_cTe-@f?&`>Hj$5d~(bc5h@irj(63e7l4`V7A_*IwfD0#^9 zxt;(0|Lea$vTQhJ;Ubab(sBM-TJgQGF3!zoOnR>z-FK;G(zT7T*FNcSUhX|NwQ#B) z|D8BrK_#^_y}GWMsS6{XtP-++c(lvlX69M5;7gWeElXYtd%X&h(KmgrtN1s3@)CtF zALj0huMkgoP~Wid`ib~AlCK(6exVKRsEDRjdowdI?B~SVuSKdbpWx&Z+K?>j^n>TY2b+|b}OwOfSC(euOoZLzPNZmQmP ztzn&iDExp+vscoeNz9NmrG-`5)M5UvVK z%n)o^I(O}sB%8^XD<^GH{n|4pell<9n{KD=3r-glzFN9c!gy7nL3sHkR}Z%PdzNXs zDd~%IaYgo86!C-{T4=F%X7XvS@L)Mk=gI5V&f1i?Y@2WBbqgV$wcl=)xq40B5cfsR z{K@qq75(g;Cst*yh|-JGds&vFSxtqW6>w_3fjW=B_c(+wrnO^cgb zwLZvR75QtbHe;?sNYv73$81j?&9;a2t2Ra@ZL;-yd#bI-XjxX*!Oh~lPxlpz&7H-v zP4MZ=IVUw*of1sEBCSH46TjR{-IcuRCr9Yi6&r+^w4TpNGWs&5tS6+(`Q(iDbZ=Lg z(^t0pwS3zawBEA4NGI27tJr%d-`e$ITjbAuuU4w}XVx>@@xRpY;pPR4y6ax(etDf< zw?89%%c^GY68#JQ6U&OXtY3UnoTs)$TY~HT;&--hV>6a-jC^rJ=haP_Jvnl+Dz6n} zZ$1`U$8fFd7vD*Vb(77dzsl}axMSkaVt?Q(=erN-S9X8kowxMg`wVM_c>amY6i8}*o6$?gdD-OIh=2GS{b4@u3*VkcMnl3;J)kPxF+xN zDObjJ!2_~~UMglX-_U2Vu&Haj8Lcu~Y3+fBVPD=@RP{F`8LO`QF;TnEjw$_`R>o2N zFvTMEciZnDk~ok$Yx&7sW82MEo<+^iX6JF1v$pJS^gCj}^l@L8BHzawU4o?_jk*(W zy*Y zvO>WwoTX8_prK3Yn9B!66;a;_ZWW1}wxnC`eK+&%!XJWv^!80~QN4JC|3SOaxw!L1 zmnJbM&w6w2%(;JQY2|spKVRQ2&yZGlyP+?TIkTs9vdOx|848`A%)uK| zYHoMdNkOy20VgM$u2!qOx^->W>j?jyIXA8@t)0)>c170R;$Ljr#Eli-KN>j~om?$s zvz$l!_E+C%yT`NjBK@Z9;VM5JW97x)>sfy~(;(&j-Y>a7pY*=3a9VlV=|<7Xr@d{y zt4a-uRMIB2$n?EizUFi3zq-qDUw3|)?bh~h<0b8X)1vjnO|!2~?^~hUlW(B1W6jDZ zx5Ol?Yy=%&%t(Eaed_FrEiZz3rDadZCLI;Ocr5Js`_`Z7HX9#x+FwoicPe3HaPG&C zizb%DBuCgCl0BZB-eh;7Y}r+ZSo4}Gx;)2LZP(TQ=Is9UsKe~@Q&t&t^K8o6G0)YU zN7h{8S@O&5M_WJK>*NbJuedR(%W(PQG_mal=M5^?u%*nfICs?Cf}4Ct>-W9UVAt}`ozTC1uy$-{FoY- zZ0M<5enH;pc}7@V)T89?sY=o}x;dP)4NW%9;ge{#5I2&$%-7X-r(ETF_p@}Bkha+Q zd<@!*8!nXOG8QWacs}>M@&?1tE-Go4Gaww!RAJSV(0 zp0D!zN%y1vGmqMCxXUcL&eh0gtA!>H>+xkvv?chAq&UmsmdZPLFAp-yU z);`&>>5|nlPich*Y|A}sI*YSs-^lcy6xS&&eur&N(PYu9D>g3e)49BlssBLq6v-JP zMi>4yKa{Nyk@)73mvr6liNV~5=Xbujx%v0o<+iK;ex4s+$86BvZseEzk%iaWsBOcE ziIWu*Bi+QNAKuZlT1LL>EZ6Z>0lU?lkCpaGE8cmsL-nzl-6X??(!M=C8fF$;GUbxL z=H~ZQ)wCt*MFO@QZr3ghXtQ~yC?BgdFbwSw=FNlnfV;vd8nZHQvBai z-tawl7fqjiD`mp9HMw&iP7%m4$x6MuZ~3&wvdTe(5?q%+n%ELT+o?l<}N? z@}nj3LUi@M7Z((xqbL7#f3-bp*{WGneJ*a~-8|{_6rZ2Y?>G2LG_8siyi$5&aen&F zUoMljf8fiA&bXacRk|m?VngdHr_09!Z$5d+afoBCa=i!dy6YRv6Kl)V5I{U|z z$HuIxIfh66{0RBLFyC0`XVtF!cQdXDZ}v``XZyzEpk)tdNW7s6fBA-_a4FNfCF|_3 zeo2#?lRc})v{)rpb?=Pn4I4W@{yJjf{6ry=`>Fe`X9YED9b6+Tu1M(pY}-=j;c)ma ze@m>EnE3zb!WXO)o)uS{C$z@uVC3-i};y%~q(^JCBqg~lKrm-}Aj1$Ny6qx_e ztZe`5W2dAwY}c~x4);@Due`9xZ)Id~=d`7I7V!0)~HOfSDfIntW5Av<2S}V&W}9{ zk3GA1TudNM{Da=fwwFC$4868JT(@G{q@IV&bMmA9a?8}3tgfEmbyDQE>wULJ?kw{b z+kZ8#V9*yl<)wV-{fw_i)c3iU@E2)%-Y#hRGBv$gLc`VHYyYDV%^7-j?kVEU`ria} zm%1+rkIJ_#W)=##uRJ}$o5i?0aN1#sXt%Cdx4zh=+X}9>?t9R9t9@M((`1&&HOn$Y z9cI)8aV|OWOkuYjC1YSmoV)Rw}Rj29V)+`=uEo8cb+47 zqJYc%J!}_Q(+k-b%|G`jS+hYu<6qFE|7c^PTeMb3`7$vuyk}uxa3iKvaZW5w^~@_y z%`46<$t+6^E-A{)OLxxC1dzVs2XojO$h2gT6 z6}~>RuJkWy`Myy0)q}F>XYxAhkN!WmV zEv@-kbK(BvD)%>)b2PU%^?fL74SP06ZSzHKQK4tjN>hC8r_`-bDpk2XF|2OA;1~W@ zvA_kKewV*5&8tx2Q5CnzWZm`r+m;tqm)=&KO3jJ?5qtUT(hCc(Ock6`+H|+m-{#n* zqSp>GoY#fx&ihXI`p{?VNz;RgOPJ~pzmQ~_6Pv$j)4E&t)djAL+@APiPn{ZH@Wto5 z_TI3bFn5LBoRwy|clr-1FU`Bs`}x}JQm3c!7g|1Ud-YREO5$U5{j4+r&GUbs-nh2m zLGCiGEzOPZPAos=F%_NWsa2QZekW3pkhFUn%30bc*fwuNU?IZx+AlR5*0S zwTq*B@m(dKW$#neGrs+C@V-3Xp!VQwL;aLLJiZYUuIKY^{An!|tuKy@IHqoASyN;w zbUfy*?V9pE8v~x0=1=;dUO(`xkAvxaLjJxeIIz3~GW547S81Sns_2@;pe(57HJ4 zE=ep&g|q?BM!YN+4i)*o*X-<)%Z%O=wOk8bUQ8-!Y_bl^c3rc@b*-4T9?!`Md`pZ= z(=IhhzuVo>g)QV#TEXqbnH?)$a-#woFJM~TvyTr$X9CAq~zH!}YYOkzx zIkdRuuSC3kT%-Di*h7^A4a%XsQo)=7c%feAxHfvd4`4W@k z7iAl#>7Dl5rjn~W@ly07&BaR(OUYb0<1#(*m3ij2#kafFFU4yLd{gDnvEp6n?|eh_ zqs#_|PIV>2#t9y$WWtL}InN7kUGJpN%rdL{wH4$1P}yrincFsP%HC|YX6uqz<*S!c zE%feA{VwGhTzuKgFwW*_)zbfSFP%y8PAHe&9Gy9%?Y3IMg1WXi?RP65_0635k++s< zA)mJ6wYf(%Y+oMi-Br7Ko1yE3ReIX%mi{ui{op-=L=ihrQRr8x%Tk85Yt<_Me|p3v zqNaNOX(~$z2u6Cp8x({+srnzMX5hSpKcbO;o9+0_wKELgu6L?t!cY$e}zpC z5sZkkPoBAX@~-OUXHRrA*XHjyY}}{O9%NO$@{G6TshHEU7v64lT&dw4XJI_Q^5tal zw+h-SdlVAeTf|xGe*b?SQ}`w|E^b;*?5Wd6aZ#0#=T392JDgdd(EdFt(|TQeenGTgR5`oZrF4; z&4H}3FG4nmXN&CVGZnfguw%)J?Ynu8FO2#0xz&d+>foNHf@_uXg>N^Wa|vYq{c%~# zc9A{3%C`SJmw43*&bbpLU z%^|-3BJ(|e#5m}N*>Lea+;CuN`VC>$sO6DoCwDv(X)ryzLYKqj+>&LiSPAH+{8#y7&y0*SnXAiSSNmVor)%sCnc} z@UEF9>^6~euY6YD&%?1sRVa9caZ-j@$i~(*_lic@tg}A%C{=q}5dXUwFMTv$oLuSlG?l}4+lkDXHamAWJnlZfYENSAs)?Tb zlE-)Srv_>KYWf}@TI>9edxM(vYMtp^L|qCoJ-H7w9~vp71lV-(7kww<2Sb{;4B!AzjxE{r5CVZR8j4 z_#2^g@5t4b&4-@%WEbc@m>UuNaBjqPq4!5_O{-GBX72J`c8A`Dce;}fMc7_6JW;*z zfrQqt)l)8*&$t>d^;hWOz4ZpaHO~FgGiy6_=Ifk4*;dn!9t#X@Dm{|%PVrI1tH?m- zdkq!hlPnx&?eC8&6v%z#T+-EV!@a8ZWh8gi2CnOq^H#rF z$2^m^&F+TtRr@Cw8+1h;54d|i&hGhz6xFoNKOYr`bUOze6`z-IG+l%v?(IXasSRIB zIG1d_bk1Xaq7?lt5v(Aspcye3dpx=gk_ds*U(EAggi86g{@;Rp-&YgX5_1nZ&j;-$=?61GH(dPM;uuU?LES`1c3kcs24yiw2`^deg zWBvy3KRtmTx%W=0J6!nDx=yJ6L3rVz&*4*QHg$j6Hv3Ub*W)KUV6jkFxy62=kc{nY4{y2nXx>zCPww^o?Ch?{=g)aV zdmdA+U6|R^lsjS~y*>LDWTdR&?s7Z!HtynwmGb2idW6<||8cKV?L2Sdkp~MVz6-xl zSJ?4?Rlxr_PE=gMd9GbTeO7|O!Eo_wXJ)+< z%eP+iu{ZzAN$=*|F0OmN@$LNm`|Frz9G%q1Qe-vh zgu17eMro8gm-*G3J(Ic$r#zmKdSQ<4`63sY?GkPAKR$Wu#Rjjdx??Qav)pOMrmW)Y zrm<~1XDLe`ZF^FBTPN>U*v3~TvtzDgPcvZNe^PmZYFE*9p=ssw7fyQ1J|Q}}BXq9S zQ{yE(;ZbLKgZDj>Hk&(Vo#pI1agQ$rRaHoQz85v+-HGDUrMee6CY;_R7`F9-b@#e? zAzr&q+ULz!;;FyGJ#f?3@|xM=xq4DZL;WY+4$7OWcq-NTY55bi!wZ!86t3-7*yZs1 zb=@gJ?Y_f{v)?UxFZ(Q^VT-brk6fqK)p=*v9yL7vVcGi5@82|kM+^Q82%2tm z_(@@m#Fx zX&s5bRQ{&Uu&rd4RIA$FocK!OFsIbq4R`*sUizDve{&gE@Tqwf(Ixvu*DlkvY4g!f zPkHfn>XPqLww_s1Dw!QCI4_U00zwPe9i??KWpBEl{eqmb6oOF@- zMh6o9u`k*!q2cr1>31E+_mjs{nGT!f8645{RM&L+l-kq&pgOV1lXaQQqZnIOXx{|8Fl(wf^12^83}}o&yF4mO2LS5Ob27lxmW3L+-}8g(E30-Q%d6j^heR)_izv+ffi^}M!s2bW ziiv^Y2pf?tMQFM&Pb^BsXeq7@y`6X2LBMwQlEOzjRHm54oKjt)>3i4vmV@Tr0!>j@ z(S-`Eu3Guh$5}2PS$B=E)&C)P#Y09Ft`4sn2Km+*zIP{SIqHe(-a+qV_!P2&thbFV{tedKH zJLSYQnLzz3tm{qBG+o@l<-fDw?5$VwXM~)uRA=toIaBThU(D|X6JDgA-nPe@hyU}z z4+%d%ayh45T^YE^^W}{U)78r=j;-eX#4|;v*<33o;?lRdf;07;f1TWxZu#vLpZLD0 zn~&v=ZY{lSb~LMb_d3rVtp{|9_s)C~8|?dUTh=7iJ+(ZC1NMFC+W)MTZTsuiP3k(k z_nG{gqj<(OR_%`Hl4%y-+LGl3RQgn^_lw6C9CN??W_j<%uQT$0iw2$c^OkrvOSQM; z`pe9fD-=7Yv|XNY`9goNR9;fV!98u!5dj-kByyhSDQ=v(X3qzojcJ_MwYRUTn~}I- zdi1KhX)~(}oBE0)+{JZys*_-bzEh}1= zW1u%_;rXR*@|OhIp4Kq0oHD24Q;Xn2MJa~ElAF9*I4;k+q+WKg>v~ihJ2pgQJX`W*)z%ytDnsviw!@xAV14 zLr)%9CXjqtcGpqoyNlLWPyNQ_w76eHD~{u4*(GHQ_tk!xDlaO{gci-{(-!>s|DvI5 zhs9cBm7>M5-8axi4QHr6IJ29HfngOJ1A{p+-6cpSK=05`4FMPbb?>fS478G2&$X|C z=Pp;Xk%K@Nhu%e<$O9>DNBUDuHZRHcye(MYX5XS;$Ee)-aP$9TE8=^fZ{zhd)6|PS zf4cTu@jKh+U+VsT{mI?nIz=_bKzZT({l5&fll+<(LJu){DT%r2czP6D_o^#}S=59c z2^P#&x+Ym==5zatx#T3))P6?|Yj(E7Ygc7TTd#e#@8Q0S-*rFngw5TR_x9zwlW$LG z{&?@xueiJV$?U$^vRUrCR{!ohtq`7byWsS+ca?04(^mD}4v=0~mmryS*C_OmS?TYF z<&|r9&wJ<>|MY^yk*b8iMd7X49EUt~n|S@O z)3)>HGrqH)n=kw^=9t*F%n$3nH6?1BrAB`5>3S=cx3Z zyHw8FP2C&tcH6z$2PR94jn>!LUR-|lWuR8c#?%+P7JGN;IWThC6{YY_S~@93CD=IK zqeexwQL*ZKf%@;Z#7j>F8U*hso~-Cz^nl-Gn$k|A>aHi2E!*Gltn`1evtdW@n}W66 z&pnEFm*@Fdw3ZajTr2OX^si6x8*}A1;}g4NjNBu4mWh9Fn!DzKU8apuJNt|UH*92c zCYdPi_T61A@iuR2Scmw`nK!w=h`y}n`uSv*a@wJk$bV0iCYes^Tw%ASy4KD4z*E2A z8OM~P{2vuIoL?;9!MfekFYv69M$i?DmtRgVI~jb$=4R%k(-QxAQQJgM(wt{3XJlYl z%*?=GL41C|s1K%2y6+|JC~|E7=E%*vmP!3Lxw`Gz3dY4E*S+$+mIN>_ZC%jnp|@wY z%3-TbGpF9{{J~W#vz|r&7enoeOUAPsI$BM3i{GEQ`TkGYb94Uweg06qLHUZ4$Ogef z$K4vvGK4yPjL1y27v}o9m+xTyvRcoG98F#S_U3ga&lk-uNZUE}o699Zx1FCYUf%Ye zDmd-jy0uz|HO*vD^|?pV{`f*J3lb? zt>hBN2oKZtWAjc$|2}$zaT~{H|6elKzN;;s?{qlCINM^vEg`>GYqblN7rDH!$ckQE za%aY@9Rl9JeN*~F_MPe8BN!g1e(4m$?UuX0Qa-e|MK_mMd#w1|KYRC<%L`wgTz@Ke z$)_{L4~}UkvK?ixZdv^DyMu;(+{MW6Uf(JXeZL$dm{|Quqve&#N?Ts`qs~HN&M@) zDt-9n{ST*37&JY8ZjgQ7kTjg56u(PX`{EuOOA})5D>r!S+9T z-Snc0z1i>h7WY3A{-LkJ)Tw6I@P0{QS+qdTWT(mJ-kmvD^ZD$VcYl9<{;S?#Y4g(6 z-e>NSEqx|kPP$7C7C)BK;oWRyBGRhCZ8-fk_!m#@_qk)IXNHFK#TT)xE))`+TQ% zo57tn17#8pHV$DuACI$^WhWb-^bP^^eKB>R6+ZRLy9Lo8~ZUrG(n=3S%DS9Xu!IdK`Y@rxKLme>r1m=Mp}hS@##D zO=f@bvuUIJ1v9>hLRtDZC3V>^>`(ggV!`_>2lbAst1OpmJZf_CaK@u;6){Wqv!#Ao z>n5+J_Rh7t!F6?k&n$L>vi{ZCN|NdmCiyPuS@FkByJG66RZKVM=*c{)LF4=X(#mePrgDoe)QWOgb0H{2Q=w&w zfQRrTwNRfdk1u9Zd?RDZPQIJ=WBq}Xv!}=Haah!OadrJ3i+Uh6M|cOo-VYYkemn;8SZX z+tx>Kqj{W7Z*QHqt?!%8|D89AHH>3EOC7o?x=;79#mYAJvI7%tTJq(dU)#R#t(o?{ ziyJ?DdWL3$r?p0G&e(N20`c0`*K&GP3!{XZ~cZZuHwj#aZ z{znRJ-cCJcUELn0tW%!O70TN!xa0YTrJJVA*&mm1;xG5Ch5Pf&V+FS@;d@=5beSvn z_#e%CRo~x8hj^{8*4>|&d%I|hPx$W2$`cKZQyNuuvMbJPxH&o5l_l&dua5Z!=if(O z?GqHX71;E6Nn>D&#HIb8(<1(!=Pp?q;jYs*@%5FY8L$5OUr;U7*)A#Pa=tC^R?2$g zhy~@#n-d%2R&wdJF3p&rG+9S^_Kdv@_3nJf1=f1be8P3C*1j+kz)`?Q?^Nx1sCV^@QhW82 zl}{S|)O&ruiL$+3Q&rV{OtSxYr0O0gGt=llEV+Gk&tGK!cby#4?i6)wa^XbNO*Udv zV)ZkYG4FUX|3IzJ#eGfef_Clg6XzcD-IP4#y*uOKC%zsPyZV=DF6h|!9yPfq_|}E8 zFflOfU}0b|BBq|ltl@+2=U)mC`RAu8!d={O`--Kgpt*$f)xDBEYC^s~C(@d39=@k{ zVT0b&Yj?`r{;S8=Ts*@5pW(m8cmUk131dEnf ztCW5Xsca9J)uGQkU0GDNwr7un&^773#veT`_Dx7$F0bj|XMI-K=^nSfg5<(uQBQgL zHVB;%ncb?kaL$kLlKE#cjh4TCRG79S(phmrd*dE~-&OZ)yx(h1&^#e+xoLye*eeKm${s~b*w1@v`l9XXrBGZGa zHchYERMF607-@RU{*dps*(W~C&RsLZEIjA2*==23{io>7hk~;aymy%x7_!+R$EoBO zrR!tgsQ^h#DxrDVcqX6rhMo=*4ivC0pX7PSz=1>HV{3Ua^LcN zwfFV=YuOXHwwoTAaP>mlMzg5i%_ld`e9-LK8?I$BPad{c!V(i^{%aS}D(R_+Id2msj;k_S@%9|905-e94>rPi!2y zk2k*jHP`j&xrf{#YL@;J z-%V6M32NBb1&H37njx-sf4boB0HejbIZ`+6=14zQwlME!QvkF({i zU_FXRTI~k6I@3Y0|FjKNY*S2v3%Kw*R!`oMn3YhLLmT zpXFKCz7=gGT41p)yO8 zdYa+34NrsZ7$m*Trfr666_P`OAuyiOlW6m+L^-Lc++>s zA)yoRgl?SOlz6k*wt-hMhvE3~y1(~kUp(_dvhzsS5P%T|9AXo&n2 zB4_n+W7fZL)BV>sip|gX(afiBck!dJL%Zd3~{qeyhfxl~^UY zFwN~(sz~WXru{wFmINFxsW~(^wMQ+vbjkza{URHu7p{_AZ=tqAZ2`-iB~Rw9nbqm# z-~E0{jg9`~8xK~fO)kCSu~BXLs~OtCCNo!^G?D$Bx~nhB(l_wjg(WV>ld2rOO0TS( zDy4aONzCyg)epHdpG@bfa@D%(H8p1pYWK@T6=O%&6;Ua`{%4Y8DlzU z`L;D*PuDE{&a_`AWWRXkrU-Xq*3&{VPd#2$u$)?xCb|A((0-lZGTrMNL&H{FjI6Va zu6!;N`zm#*-t$vh`%MCb=e3)>+7xx|$5WfFJHjTbH3rIe&e)jxFu^A880+J(a#g2` z9>GdtU0d|K-@V{E*3_BdGr6?snU=o3wl?Cn2FIFLJaIy6-ubm0x@x%j?w!!RYBLsuP5jvu z7#MwXsja8v;!7)}n_`!o%;wta-qiU{LN{jO&RqxoN%FbfTAj=IIovh*?bWv%6S8lt z$>VHaC3`!pRcYDUH-B8GSXxQ&xtE)@l+9e*x0~hEFT-m!$0`d`&zOkH8os{%DZplj zZqB97eXI*TG&Xunhz>ec{6(jG+bpm8cUNxgO`a_oy_|!!b@d|MgCBk+=q#zTo;=UR zy<^hyn$}(0Hm_W>-Mg11eOiuGPJZS#mhQEtzI>`rw)6a1VyTzZ;i+A=b;ijHHa7o7b)ejy%JkIxir*y_VmmfJI*w`c}*}1#~F3@PquU(&+bmiyOZXg zjGt@gw{7{NCN6P4A;I%3bJUC-+l-CU#dz3*Ox|2fS>44GHO0bSF6VhA+a>=c>sEI& z=Bf)mJk*iq@cc%`nyWpk#mDA_o+>?h#_To6BxBahQ){`7PTjZj_F1__M^q0Mn45o{ zHPOZAZ(QPc*O?P6&&~>qx%jT7%Qc*LX2BkXvIvX0nvd2zG0AjG)9`(|C-GNH?Yq7Q zdIzuM*za=tv_+@c&%x~eSswF0%YT~;*ep5r`by`xu6w{1&T=7Z`T{sA^3Z}=u1++w)I_twtlk`-;+W6n;v-kV*z ze22B^%{-5YG6|uHD->gk3rL0@kX7w|yp-?gp;boC`xL{q7UfG7O^xxb*mCXarl_O2 zJ6c0~jKg;>UDZ=KpDpI(s~lGyv0lld-k_>K54Lc$Mu#5bc)KfXo$-_CqP>dd8t!*A z!X=6(*W8{k`)QVQ-odwRceJ)kev(zy`hAD(=qAPOr^_QFb{<&L)hljvt~B4h ztBZF}bG<>z_89#J#p)+<&inMgXB5qEG=Iw8c4z+4c}&^PasdyY&6Jxjce2AunvwVC z!kC;B&7TCm&&-p(*e~Jw@ln#zgcFCgjvm{YJXft+^sikQOQx#8#x=GQX`DCrHkT)@ z+LM}@JUh-NxvIvkP$N!;=lGd~Z2v;-_qRH3MlId)V`I^0<+eXAOP$_sN>CB<6=9E# z?p~a^$MKWfBx7AOUmL+5)voe3A=#tGTInH@Kg8Buf3m#IMyo&blkrr(z=O*Yy>$BA z4%r^nz38DN(G_rUU0*`Wvm;KrM|EXwHj1@H%=x(T+ooM>jaTh6N_czt`jbNEZ`!VE z({kc!em+}w`qiN+)kl|ISyA+Q+NL1qc@xxMUQtZ$RQ*0lv9qnl?!0u+k$G?LkHzUIsH`7`dRe&u3sI9RnP z;<=sDV!sr_H(`_0CPWID8*MJ|Njk-lVOU&yaK~OV+r?gmUF$9#Ji6t)*5Z?v3zvLx zDUEo*61rg3>*KraXYbrpE0KEY0{`ZP^0Svm?-u2|)|Bd}@$_?{zaS_1r|*Y}bqr(`}+-_h1`fQjd84$lcd*Oza1*gQ#l;3vpY5Tlv_>-~3P|8y- zq0x1wNpkBY{q3=ir!(_5J{P;ObI(jko3|S?%CEOgxV7BC_Vv~2Orq(=ZGKtmN}C;& zD_=)u+vSCc^VPF%GO#U?noyP5f6gQR_wLqD*Ncqr33031*OjhR{}&@Zx&0-}{j$x+ zot{i&?&fuT%Co-9eAnIl+T0Bpf9}-n{mXmk_(ECPZ{aPC?Zv-1`58~VjF(c|rTWf6 zr}FlpyC3_W9G&>o=3c{L9^dCTZQf_UoF%z=-Gk%8*Vb+_$a4>hx;pQpqYZ1c;p;$a zg$zmWy=sS+OJ*7x*Q9JVzv1&aR>fT9X|LDhW&dtEp2#qLyl}?NSyi?xS>8Wux_0Qr zmU;W*CtlW@|G7}hYNJ?#xcbUf(-+Hpb!&T|mi;I9-^ZOfz4@WF2U)&udi-8+$wTQ` zZ_9sX3l>>ED!R#7aq8E@TswyQ#wRKdJ=)W}_MF@dX8vc7{Q7ui%auRO`xgFjXDz?x zlr^nyM04XJe>!ID{qWwO^JAoswhrUt93}S#skg`E*4;n7vHkD&pU2H@L>FJUW1f*@ zceLWy#OrtX_wIa?`Q(dYd;!BT#-5B9$4@-l#AVqK*!#3r;{o>};SXODw`n)@lG9`JrNx!Q2! zHSrH$ABhOX_%(ToIffpH-mpP@a(0bbs-R|<=vuK0On;v4Jn_hOs!#gl%&wy)t6X1y zv|1t}{oz+o$>jM*uGnfQeKiSs?^tKQ?6Zbnb)$~Ts;`=xnfKY9Ik9l}G0jN3ba!v- zJx@=pyQ<@LcHy2?o8(nRLZTwBbr-qD-0SAL>c8eI`=7ll?(`mNu+P*BJ?NXYYt_Z* z$>CF$$AtY=cy>waUr?Fo^;a{MO5K;t6N+y?P$_b|`ww%<=n4VPPp=tHhCuh1^ zOR8|0hx(#Rt$I6|#83NnoQym2H1J;?oAp!6FDFj#2)TE}{E2gnX8i%vPm=2<+drcPhUpnVU*f$CF z^(~sWwk_6u#l=J&t1}sYgqA;(Y3o0{MQ3~CE6%?8M}qle&OYpM;#zy=Gf&a!Z|Ms! ztPT0M%|zsqjB$(hI;E5e9o8C;{KN&r4bA3lbnTeFEql)GH6NZaq?t}J50hK<@yWG{ z?me#a*k_({y%)K4UssCXyot?rXU-jL%ii1b`+n$`#J&X^FR>~1tS>QG;ieeus(1vCA9sQ%3N(aN(a@rQR<^grLz_`kqx^c){}Q=O5R-dA?}a z21o3YWY^XCYApQggO0A>yoI)x&r6oJ1wXVr7w{$X#gz|p+MeI!y8DrJnQY+Z-~7o+ zGx;8}-d5dtzQFp>hCi$1i)WwaPmn9zKCQxL|I80NA25iSg)M8?$>05=yH01>`)OCI zv~p?1-?PrG&%I{b3@g<%&7%!QCx zs^tz$X}Hfj=U0JP>)XRm8lNsYc>4Hvwm+@u@<%N1v}#?^-l(s9f6`v|sE1yD6-4T)I&alhPFy@U>`CZ>uv!6L)3{^pNtrTdE=^5}T)XE& zyQ$djtv^qSwmz?%cl_3~n5`F7GF~0Ek}_GZlzINA$_Jx`i;r`tTjs&d80 z&lQs+pKsmd_H_2zlk0L`@6yC|QxoO`C21rrZPR@!zHxoQ(^Z@7a=g~hHn_WTcGlkIZye)T z4`+UE<0=);4_ti5^>^R?!#{QwFdSYbVsW9){@$Z``N0>z@32?af#9M6LX-Ul-N(J71pY zTfDZt{Almr6`5gIKQXpQY3bbX-fyKAy3FB7^2?_yJ(A8>hnY^vuH4_|YWqR)*dyh8 zYVw-{{@dDq=geN*!QYWm7gx|Qu2#~*Ch>r-Bl*5GH?voiibwTNq&iLuIZu^%7b|KNX~wk~Zw zpWjZ4?-$o>KR9QL_07{c&(Ed&^ZzrQyV&pB;X`wO?vE{>miXq(iQn_D2xoR~Wn$s7 zuHHCTyfIzgW~YB*R^ktZG8@+7578?d+&}G5)jJ-cH0RF+rJo#u+}ZAe{)sCij;F_n z&o2<)+`n=DdAoCV2lvf>q?fpV@?X7gwsLd#%-zwm|M=$pbN-6H+3$E#`DXmdf3x4L z|McX;7VARZm+MpOWnYE~YsP(i`WJ0vNviv)-AQ%^1_mLlBTL}nq!j3$Y|v?VkddXS z;hE*q>7sRRw@-XJ()~)cY;uTia`d#P5|bD{25s+~J;7pI@S|hLG8-bZr*bA`O$OwR~~ooP?;7l#1J3$ajL;SDM#^rVg2hL`zC(oE&RD`$DWmsqPe8b z?wM;?+^Vc?ylv{qr7LIdS)l)#+0}Zo?2JpF4|BarGkQ5EPh8je;K^6ZmCqTietwCW z@4$y8nM>I(O`CU0ikbW7nFEt|g&G7ses*cnJa6B$qGOtsk3MKjl>Rg=(4c7BVyzH) zyW|@tl}n-?7cOAg)T3H_(_?C*RL|*cOZy{VJ`(!4GGo<&nP)^;zU=c|rMtmdd1lNp zxzG!8>kTXtrKi>DW%GPLr^V4TThqDX{D)=T-p_enYku@`EV&eOVe{r)Qw6ibBcB(X zQ*LoQJCke6o+oEG-5hInPq`F+`R?ZO&vN>&8}`o7thjOQ!R0Vh?Kg9*(QLA-3p5=nXUPrT2v1e_irF>Ef2& zjWJn=&T#zNd->f~$ z9ywe2_O`EIw&w-vPg%9bG%ow1cm6D~((OY2*0yd|UxISY^q48`}F_rsC z<;mB4{H0F2ZM!ow_uersp?UnO2*16t#q&Q z-lA@XvKK3-xXih_*)^H_SLC{bsn08A-+H&$zfP?F|LMQ|p{sKWqpa4iPGhQEaQXU) zp2#g?J;{<6zvY^K_}zM0itjNO=Us)DJK`czy0c##|2KIB&(e*H zlYjE-!ZOq7J$wEMysEVCaXjVRV8mx1l6BAHPTQm%%=iD~{>WEyF!V4i5q{Msu`I&f zut@l8ZT1BbAJyv{oDYi5S#n{)gY_A6_BE^BGT~?am9My~ja`I?)w*e2tdGZ~4XGMa ze%La}F6=uX74>&UbnO4@uYOsZr%X8f$-e1WwtD#5Kl}0*UpaUF<6NGfj{k&jv`W5d z>$R;A<$q{;=F^-B#Z_c5dd+27%X0B}9@UI_e#cmvSmQm2WFMSs=6gS4?|t(h19* zhj;I3uduIWpPTaM;K~ClCQn`1@F3(YS$K_nFP! z@LD_GnEbu*uf)8<$=}W1Gdk)X{1#>sl)iFe)H30m1@7w~pp1m;0sN*EjK`pOp&Vm(ypa&UtZk^N)(qh1_S=W9FIuc<-coe76}_)N|u~ z(y?-J59dtiSd{(pi=F)3oLOG~c&*DiZNrL)JZ@mL*J7M?7z_)`y$-i{7S^R?BhB4&vloiXL{ zhUxBK@_*b`&=h#Q>cqrtI_EttIGz@NI}y0d!;{BAe(h`($E5egJ=ZLMcU6B&h|l~m zZM9ZhU}(wGdR|WXtGtV5?R@dM^W*RH(ocIVRlnJ&zu_)u{?-v_uFM}g&r>dkn^aU zf5r+H&nxNmQ$G4dCq$}zTzGlr?dtQk&+oi^Z-2j@VaB1!CYv-?&G1leO5&2;F>l$^ zwyu*ZUrdB{%si;;qgoif@BNA$+V?LP?Ogt7Ul%)|FD2nS$BSV^X8K}&id7T8+vA1YI&aF@~$Z6TGaY6ly#%)Zm*-Vs)-+` z_ujH9kK0^2S!w5_*u-aA#Vt1N2WMwNri={MK+jT*`fXTx*fJ=48?I)8)iy!2~j z_x8(7-gI$8SG!c2!RiI}KI+eYMjig*a`%bjGVhiB4wuhw)4KXW;P%EF8PrQd5# znflc?PLcn%e}CJ%9_=!w_tP?$K0FnuV;rEykOV?4~?YRLcI{QRn(*^ZeS0VL#)Ww30$D zyIbp?IIjBT_YdYBGD&Y+A}$}z?|8rHtw872IlKCwoPQ*Kgl~eV4Ub)fkH<{DVxe@ik~8Q-XAk;@@0hYdgLRFPG0Z&b*i>AWjMv4tKepZt`_yQC%VWN_?vDkU%?+)2Te5@Ol#`1}eIma8NYALbeBG(7r8jHs z*8^^6zD2&cDZAF?#mpDy`d_GC_xW*0b&BvcwmzYceXV^mTJ7`}>ZovRwDKF5`^|z4t*XY_Dw3;yalJa)$LM zvUv|3tmo@3uxOrJ&K|Y#zD0;Z`a63m$$&43%g)W7nLDND+*{k`d1sutSc5kU7t3-l z6I;%>xLde}^R!`1|HCg)uL{|8Yn?C0zwlz3HP7{Q=Zh?+Suvk3`Nf{(S!b**QD+)bH8Hcz`iyx&nW% zQjW;N$d+bm%Hb4SFo$vs%T2k`{HgZXRQbm zzawkrY-l(TIG_9W7FJp7j3_>1_C$q?dxh_~D@wA(zT0(0+P%AMr+KhamoSs5bZQb; ze(lK}?h7tn``usS=G$Ji&2mCh<+e@R>LRK&G>Z#-63kQeHn4by>G1vtiKsZasO#(g zDQPaOAqyB>nU=UOkU0`?JY1ylF{4-Rx{SI_YmdpBSUSGVs#v@A;s)Nemx~W)d^^LN zer4vOJOPV8$4yt>j$NSV)+lmi(*EKN?3Q021fFKpRQ~P3QE| zdX~JAFI7~#xcQW};XbabEbmkbmhJnprNM0f!kPLdx~yk)7Fn;%&}BWVwLszL&&(@HEy_#8)X4mA;i+Ft3=Hb57#(P^ zMR1K#nFYZ)nFWyAvNa?+__Bk@ziDDZUOQMrM2@Ub5ZL;WOKXM3qpeNGUyT;%dqo6&B&`}w!p=a>Hd`1w=1!Fa~m1%ihq z-yG~4E@GlxyH;#b+JBbmDAXINxEnzL`7x#243V?Hq_9w_OVCd&xlD!O?@ zlsQ~-mRgF!RnfL9A5J8xy1Y`?&Z){2JS@8=gtdO8w))*8MPR z&Xxv~nKcoeT#uGrxw$5bF;X@sQU6<{MbqMt&w1Od_vv@t*)JxlttzXpdxs{sxxtA8Hr$t` zrrRcGoJiBXb40f)c9X$^w{8;YrFP8Qb?-RtZ`>{RPBHzVUD}B~zjnJluV4JD`9j&^ z4DV@&RvZyMeAFtpX4dcBO}~QISTo(SbUt=@>87Z~*N%6&cd;#+CV9q5LPL*P>29>N z<<>Kc_f!N5Iy$N-uX(y#Ogk_sLzC;P0@rD!OPSkcCuL36UAEKA=G6N)!M%+U+g2Ee zuj|U_I&P{R^*VUDh0L<}NaZin*)C^0rStE#nbecA_&4Vp=2)o>^Up1Fx!KHI5_OyJ zg{%C@G#QtA(+WPfq}S!!FKp$@fI zK-pWfU27JXa=(3Ta4CPH-`?8??Hx)Pk54?vS#oi{kNl=1M-FSeEZf<0wII{!RY}$) zw)NY0c%`?zc(MNSyBki=-EYi(v1vDF81u$i-{vm;9C7K=!g$n@cdF{;Wjh%e7$lh) z7(mxg+-em_7+sivj4VSfiajl%< z5bv3pebB6fHFTnbo=#9{;|-rTiZf41xtYY&r7vC*zj9Oi*_dxdfN}(N+*^BS> zwA-ehPti|aCiuMb%(MusKX)OHT|aNAt#aYYiBWs9B4y)i;mIkLj@MQeHQRjsXmCV1r}e`| zjSY60FAXLqPv)6oYjdmKf7#cKvPtZ_ncixf>jnH@{3Z9jlj@uUx9W|=mkKcb8_TN+>c_U6T_&C0?b+AnQnDBpQ&R}OE+p~v-)KKS1XsPh z>$JG)`^KKBtBzzHjX7+!w*B^v1H4s?fpJFu(L)5Z0@4fmN3=cgroklZtEd%3h`|zL52P z?OvPjjobfmep9_+wDt1zvSsy?AAhml?dh4c=fIhpo9}=99&7&J?(bj2hQ&WT-57+8 z;tsQNiaXDkVxXEV_5Rcc$EgO&iD7O$(^F!ORU18#y^BkYV-W-34OqGJM`|8%~LNaZGXPiwOJ$a>Z$BwqGzuit?qqMKCN`q z#1P%xGxTmn9enU|_qJ`D4!!+idAjHn-`+DByJnV}+B?n;os;8c1WQLa?Q~fYD|EPz?Y^Gp4zWLy?{uPB+aj79&Q9lNf2n6$NBbDuYPc4md2o?@^dfuWhp z@3HKuLtz%hqL(jv|Ea&hBP_W(N#xP8r$=~CwI6+3T=ZHwT2`X+O(Ta=3TwAf1-oBY-0&Aoc;u($i_-lLV$+g8?A5!0?+|t9N+}^WPH_P_Jq-`rEu2@lNsL#V5eJ$p98t13b zTQaHNCztgu4AwW-*~Zsg;S^c3eb(8TQJy+BALn{)d*d#%fWLy_(Rp^YX>xPdUyhzN zuV-H4L*5PXFE{Zzm$1bqiEvtp`ZIX)T%MfBvRK9S)EgF+U+nMt?GA0b>HL2|x$wT2 z8}I)!H_pj7`OR^V*P}j9X^L`?`?>NJTsyn`k~Bg~au-ye`Ok5C@jtDqMgKnZX&p*# z%uv`fd5N-Rd`0-SuDi(xGbU7uxxc7Ae|5!w{V(gB`+n6sPjPJcwtGSFizUVSj(6Mn zzAT(yXVIhF$K$#_=#P1!fNIxXr;GQMc?It*5qHqFD71Ddn^Eok*nm~)fL7myv+mo@ zUF2Vs{KQxFl(dE4p4m?x`_5U=vNzOw>Tzq&J%)>1HoWEhrFvxEqqdd_EgddpemiGK z-di@`<;&ZZS0AM>`uU==MtI|$f{OQS-&QJzep#E`Aout63+bCj{@r1F6e=@A@~?F4 z;{Lz;9i+a>)}D~twNx~-Lr@~XVs6MvjjLG`9F}u_Gi1_o>YIE=VOJqrx~5Ihb2hF? z^2-?bg(VnmSiZ3w=dd`La%Z1~|4ZhSNAJsnTmPe-&6^SUuXYgw1H&Z-$Td$`>RXrm z^1Ph<#1z;G#I-%Xpc9B&?`N29)hg3(;=azdx5@s2Q%ptEjkX&?lO}~!n~TdGaPHK6 zbWQRZFaN&paqo`{ACPipJ7%Dul3rar^Nx6w(6Q*-61lGXbdJUfs98_We9gVMZ_~b8 za}*WNtO<^>oS1!5T78w5)xXoaVJ#OAO1w0Ss?fJp%w4@h%jR??QDYTo$sE zPwq7Tey4cf``ypq+*Ply|I2Ye`^O0vrhJn{qK7Z_cuY1uIVbmkrSS8QX~L6sD1__H z+R<`Rttau0!?^>|YKtnYixTBtB`fuFX6i}LFex~CK*agTM=jBZ#wB7l`zNjZvFA<4 znN{bfPj8*CsgXWy;X)mmz=w?q7F(|$Gt9qQY7@8bRkdh*$ZFQzQXJu}a&g~|cD=p2 z>UMQYyineO1D{&zua&)hU~~BC6-JhM+?(>JDcZc6+L>K^uu}|_`r$|Nxx6ZCwS?BwFv#@Z`QjuDlt@c7r`XFB zb%`@;pBN=i5%Ais_OmFqFsP_>yVpVOMK7LOa9# zzCL#soLc^AH?z5UKtR&^lp}_wyY&5j=Vcjp@7umgCMYWBxZ%mv;0*6S+ukg!iTgHT zTT1XvD}D>(FBfuWFnnxn6T4|x@^|ZrWeW{eezzSn__@1a${WL9+Z|p@^>%)-ir7`p zyYR~asV!T!wS8a~z3RJ-*Xrhb&2Q``kKB)Z5LW!_Q?X!vm%D!7>eprq3ueypd?Ijm zrTOcM`N~f52}_oLe7D?U%0Ho7N11{v7^` z0*xt4HN%c4O!(WX_dA~RwRzBYYsJ5fjDI>4|17<+w9|ND?q=zO-X5A|$}g1jJkC33!=jcwU+wqPb8eAd-m=}**Dp$#&poPWyYl{F&m=LwSMwjPcrxdTd4kZ} zAD!(PT#0&fue=m_zqr!nKikEhe6_M0kMoQC5D?t*$|%l(`DVRMwUOG@ZQ2M0FM8tqZEmVZtvLNiSNL zJuBqo3zt1>zTSJe@3HEIn%9$gWo|i~7Qel;|3uE2JIk-6zslg;v+P30&aN6+{Z;1r zv3430oIa!Va^^Lyy}gi`fuW9_fx(0Ua-%bJ;}fiX3oC+D0*dl0DjiEpGE(zOGLsYG zM?y{wy;~gYD)RrW?+f2NbCV-pDv9n;@CyX>mz?{PTBXc zYUwiD)1?xA%Pt+={V024@sjzs_ z`*C#Dk{`*R_ZjS;Av9GXTz0-u%!@Pe$9deDtF~5Gn)VpYv0ylxaAvpZ`6=N#M@w?j zwO1QGsNg+r*|#v>)3s0Y%!?UMfAAE`e2%!V@x;cK*H(E2I*-+yVc8;6OFyNP6aLo!Jy1~cy=7Z( zW1o*vOHkJ@i_*(;{;X14ow8*^WZ;r*FIPTWmSw4OvuVjAm3X$}1sP>|&yE=#UOD~i zZ4uSLy%yn1mOfjN(HgKPGW|uEchce3bL;jndxz8~&e{~^+xKjx<7W0f!nfaL z?@N9DqHcw_o1#pS@uXYTU+!f{tzG_Z-urhZp=*w6hW1WUdNfP++?u;nm)(D~VWW%8 zkG#a#3gLO`W-k-HmWFd(`SQDEbC8FA;)Ht-?W$Hs+N{eH^i<4v6%?qx-|v>>C6igb zXD;wxFpB1=^`6znD7Bk|+2)wE$tMT10;c?nGGTM?e)yoDnK&zZrq!YQ4=+mZTrf_>A%0rSI8WB7|`74X~dzq&&WAC z;rD0r9laSAp_dFon^`Y$Y;qJWYD|^OcdFzMe0uDJ-{OLo`f>|D{4%KBh(1lwU*La6 zQ;30Kg&G5cBK|B7X=NOZKKuRbnvb>L=T*Pk`F!5z`Ni+H#{WNCtS@v?>tCSe)r<8NFYI6L zw|Vh*dq&;L3;!!lxXM?&DEu{bdY$k7kC&278rwV*zfGF%ZIiOW`OBO*t5bSBFPTpy zZO;(7xPohXRR&-6kptWZ*Q|_D+mXI9W?eyRyI9fE_F9|U&g(zQTs$M16S`>T^M!o# z)^CV^$SN0gh}*V=QUBA0=$gJ6aXPb#L-{IR@Y?x5{CxB~(?uP@vq2YiK3|x<=g#4) zdz?&6xvsB@dA{&!%zLGe>zfwTGn-evDBTzEbgEq3QSnaK)vT#o4~c8vior>prpZ=RQnZ_&Zdq zyh7uU<5cridpw`|IldK2d;9G|>7B)&()j;Q*}me=>H?1mze3)HSFUza7rpoF;p$3< zr*EB(@wKiGtXO{P`l>zNPhW>z3%#dXA|tZ9J=82jE~ql%so+#)t@nYI>mwvWD?%Ph zOcv`o+LIC;;=i;<uKfb8Yc8{f`yr0y{kaU$)sf3hIjU^8r?Al_P zbmYv51$oCDb>6?fcjfNpwbj?JeY+R1VYzh9-0Cm2Rmma!yyk6ZQdG?DoC&!m*1y!p zHZAY!;>--O^iSKq<=?n)Z@0F{oqOkwII;(t8lJs%=XP;ueznGy)oYnI$sS6+ux{=0 z-Mja$<+pzGio0y<_b=byzTzq?V{?8xBU1j*qo<-i|L%P&ecf$juygUphlz!Kt#dy< zDR^8E#P8mF?60ToM1x7abBly#X-Sm7Ud%R`eeYT~Bhk*oQKFrh-&W|WJwJSg{iNv1 z<`?}*Y&~f%5_|7#3cc0iBE9t9#gN`3J@3A}$Wy+#{C%I)p^G0KZFhzGyB)cqW_5NV2VdDa zpKXm@pV=O7tli+0G0SsW`183{MT;trngs7;IQ+!1=Jwk6cbxiEC-K<%EBV)i7QQr) z+Qa&TyRRu()+eR^T8vNKzTm2|_l9g|pS=`$IAzm>mHh9HT50+|Y&mpYn@6H_?%l@o zw|<+SfAQr$>!$S0k44sV9I*NHf5m~wD9;r;IRs?7c$61MRdeWSu$qe-vZc&+Q1*6zOS?5xB&^-adnV;jF@tXG`!WmaKuf0@+A#9|fy zNrLAlRVX`I&OUwXQRYV0MPl0;;y!=A`p)H4ALHHtE-523+x#Lc5&1jWGioOWD$g@3 zKGl4_u~6?+!GU>OW_rvv*t4)dX_t!7X;!VY=8b-R^0;n{ zUTn%OwHl3G$a2+{WkT5GaHl)e@ z!ZxXm8+I(ISlbwTN?Uz-kx98ts_5TK6<-&}r(^t2> zEM4`4A*|BrV@c5kfscJr6Q1S??2)?b8Xxe3t=ImTdDfTIDv!n0FFm6Jy@T54Ni~`n ze%x}*{Nq`!`-?s*?hAd~|2bExzKP%XtX|N3lYc7m*M0dWG18@F%jnFoEY3X+vJ~(yyH2+D_2w` z%jRTWei`n&@%&q^`wKtLt(kr-{=sh3eeCYbd1NoYX#di8?E5G2iPtvoTpF6@p*&}P zRqy=R{l{!B`&AzU>wF#`|G90=b{C;HCtIcRHbfdW{;kpy5PWGl({F~` z4#yYYS`PVV{n4%Sdc6GSZJ&x+vX_5I);T?X&vASHZ$VC}k5;B(QI{vK=y`JL%{c|O zwn-l^HXOVEG~KmWrc=XU$>F4j%NjCIio8mhQ1Gca^08s#X5mGantp%hyy)$IR%sIP zktxUA*~qPI_LcSGM>(sX`kuR|P|7jmql1(GvYv(;lk42!Q*BR88X->CNN8HFFW|o zPTNHK&4n$Wd_xrrH^+vt?7M&IN3P(DO@1bCHm`0=>wl9KkjLw;uIXiSH9=I9zlt>_ z_F8YNcW|$FaO10`E7N|~yT*k-mj7h!5$Sfu3qM4 zH9|SZV=dL#Tfg_+nR<#h)?D(yj;v>g{8-QK+iorNsH(>E>zAdMHC>3qE^ zIU*u4Qn0iuukPrX@cA>it9;&BI&rr8;Ksu})%~KSlB30(HkN4$%|8 z9(huI%66Bzj3<+pp0S?cjK?SKG+FGH?SJrR@|LFd?isULXB_=>aDPJAnWpcP_@BQt zUFZM!W;*ki2T$Ic+5MWXH#H*f^yX}fhH%*_RURj+EZpa_R*M|DzsIIoWZ#A(#Y{h7h*U16?# z{}*=TE&IHpk0)tYqs^1$(JuskZ5ImUteqovA@@t?ogW6r=AV(S=X&h_yw|?@V>r;dfA!Tl~WHi`aEv zWPd5G34OI7-{Z55lIoNjG51-fo0juGlRFwCZCUSY*V?D`8D(aNoRLX>t- z-O?G#q6mR#I5>(=9t$l&@pHKujX3unbXE|*nb zC%N2`d24C1$#;v;#@u-Y{myc~YCKf48adZ32-+o-7v)~OLR2Gj@zX8yvR1}QEjW5h z`T4e8b8f#<@$*V~-%&FA1t+^zs{exHs%ID9{8N5VL+`iF{X^%2zijDMW6owd-!&_} zv-Y$9r2Q=GR!>>XT2wOY)bY%R4!rXYcKcnhoXkE`)%@K8*DVuQtqj)6&E0V*>{sB+ zmy=xM0`~hJ&-wgyvhzBf^`|d9;+ea9t(%0F0cV1D0sosVl7DqAEkv4rNdD^Mef0j) z=7`FeyklF~|N4{}AD4>={IfAvw7huo>TfJ_r!V|G`_AK%g9j{hB@Z0)KAsV|{r=pA zPkqnLirQb?v+CgnQQxlnoYS==nbsCny0*P~o8NqY=E7rY;i;y!>&;dsl{}R4e;Jkk z@|My5)9ceRo*tNWX;ORUbIz!#3%lPf-&7c7`(SrP&$T(LKAqFGU9K5J?yQE@nXBxhBad^k{Y~A9} zCCW3(nXfl@i{D=={8hfo;G6fvwM2NnyZL7$t0Sf*+U6H>_Xyn#OL%6NkSr>6 zOX8a54NG5jj-TASW4zBdE*F_~BtyoQ|I1D9^Ow0h{wrpDSE@?2I4)_``sCS>Jn;*s zFSrYOoJta~m2Q3!DWUU_`{nO1%ta3D+ph20F)z+hWak8q=?;%pJ1pVoDL-M|c=nXY zs^AsQ3$Ke*#V#}#sR~}WJ%x!&*jn(SFvEYJ7@eJZD`qu(@(Q$@GKF)_#)L_4G{atW zZE(u>SKtg4N#5|kk@YMK-_OMAC$0;$>dG_IXUtAWpSb$f@s-I8swyWk%pRo+*SYw< zJmmjf;;YPSndQ37`|jN`wEHXzSD8-xi+l$xh?3*{ee7(NaPx96;hD;-cZHfx7YQobO zr!yD^2iv>dUVFpQYQ~v{ZdQc=i8~tS!!s#%35g8 z;^4L6m(VZena5i18C+X`g8TfrlQ)Y^XWUyVAN}o#hMDNpha6|NDLY>;x%#s#?M2jm zz2n>^v29=8S>4Ywlq*THdGMjtS;5Tmwd7~FwzgR@b<6L!dL|zTd@X(M0*6Ff+@ceP zYwl==xp9a8nj6!z`=sVwQ!OL4KcPyOzlvGx7hhP#y>D7?R)VCH$LbCLYNpI(3u*G6 zIB)v_zP%i?1zFDNTD)58aqCdr^#e?w4}LuUMSa4Bh6BHYzsy;1#$D(~aYX@(*|v=B zi|+kl{i|BF*TbZ~b>4v!6X&e$_t^cYt<(FK|BLkt7jG!go$8ZN-QHrz^kTUZ+czHu z+jBCUES^Uv%~yOL;UP9P*kZG9*zp-_`Zl;smpQii@Vi}?OY}ayaFTXhQIsp{E9rG5 zn!}21Pi(zTyW(rUFE&9}xer|K_E@>Rp=bK%4VR}``_DdN-dt|_w|CD@UAIZUZp)Mf zq}TiEUVF+qdH;g%IcGwWgc+~yUb^b!npJa)W@y=T%(U*1S+?0Z#NF`Tbm`qsD(dsz ze3M-fpc-KCkeip|-_3=L2XsvyeB15B+;v}8Dj?gLS@fNiiC66l7FnBV+P}k0_BLIf z>-L}L&qu9AFHB!K>D}_o_vkK(IKDwQsPD?(@=J%iC*JuV7qvvr@@sA8#M7DDf1CG+ zlwM?8^1<2QkYAMZ|CV{XotD-wtKTSlyJLNaN97{V);p7Sn7mJVyr_Pv^z;k+Pky@} zIQK~JIZt`ve~lL`SJap@e&YVPS5hX<%8P%ucRe@i0-DN3pG6B97#L15FfbV6A7{co zyH(nIkgLf-#Pz-QEwNcHgrujrX)eZ@GBw|vw&@g%mZX|2WlZ`t?f$8#JG2(@|? zJIgq6*WZ=yYlY4pV_CT|W<%UIwZw%bN*{5gkv4}D5TjQ-z za-mPgN!;)Eh3p%RzZp^et7uunv5k#^p@j$I2-5PR#Dap-{-sg?|*;$%m4r1zw!(+2iKR_bfrfvcpSO)Ojd=TyGZ<% z0zY?|^D7Qdzw&NR8~53YxLjY?Il3B;y=e&!h8P zJl*B`b`_RR`S>)g!Yh8MWF3?L!d1y4(@S+8dhxtk{`Sa6mT4bFdE>8sFm3$y@#Mmd z+{!;cKI9HgcX#`Ao~xuU-bjCTLPgu*Q#NIw}^WN*maJ7>gPOS^t0xBk3-1`-JlJ*cZ!~@%*ynuy8LX8 z?o`|D3Ac=wrLQcWEAdraC+lS2?t3+5_l&!@ozbgI+o`kn&4;N?v1=oyKkZ08ebC7< zOiOrK@;UGRzGG(_6bxo?KHhL=&7;@+{-O6bZDzf$8Di^iRabUmOK$4M{nfEoCKpdn zIT(KG$<$3xb&Zb%?n>F+>e_f$ak2B>R@TdfOJX^B47yyWYV5pct(LY}T+3*)lj-k& z{q{^}PsOcRvUp?drB5$ru8zt2?z8;Gv_n6fx3yo(nR~Ouy)^3XZPUxqN)lZBPki%q zP8RJ9oYwyS?biceO8y&8ZPC21qvJQ@se{kq>!vl%hs{lEydBbirrg}#p?)ai)U0x! z{jUPQA30Exmo3;=yU3`GucUaX@aIeO)x6YoZihu=ohsSfC97Io>Bo07&($t=>ymuq zsVA+?nz{u9j^ubREEL(1FD>V0{4{K@)81>p-}RlbQ_WexAI>XtM3H%~g-XI2{<8L&puZJ*X|8dX*}$lcU5nh>~()}o6KKEv1Xx{E+1u+xGWy4zgA(^K9Ad(%Jk!!mb1$3k=9{Vx;Lp&{*gOdUC(@p3aNCHJ$Vm!_h?R-!I$oHYwwJ(#{W}3EBNwr zac`OtXQVs-B&W~%tuaixZYYq0X}+k8E|aX$JL?}AoLikQnOSYvgfbjF%*KHr~ZE}HmsO5~%b z=X}1GHCwN{@KB_4=9z1+*9w(xSo!hsZbi=r2e#Sna1`9zJ6)`^W5dOH+-b{0KUVrg zOf5}b-OI;+E;MHMtJ9a4?N=!HU)C2m-|yU3nKIKwyN_SFRFiM%^XlI2qU*~`ez&dh zp8T^jG)MFFo2h43Y}#i%d4G7>qB;HFo-J9w*WASa)}=Kkc-xju)840JxUui$4z61= zZqaUBe>u|lb}g_|Zctk?!O@(hs-jDw(a+Rj$NLcDmh&3#e@%E=HDh_?CzF-pwdZ*| z=Ece@T-yAb*<`OchrL*b>&~*myZvG^2l#Bx^ouFTc0IiO!{^6Rff<4a{Uo~z{#)32 z>&@!?BWbvW=~1VsOFI8GfoR8?Z)_%cv$Hc^`V_1@xTEn}@ZLRVU4t)5mBk!Ses%Mk zn9!@6b+WPAFJ`PgT~-#O*C|S2`ErjJTq2iNJE+URA|kI8K7V#J6nple%eUW+Z@;5`T{q(ujjc_NQL@L5TXfccIQ((`AFDs&$2N2o3Lac? z!D1~h&*j;Zl{)v_Xbs#bp8Sa6y?rZZm8mA*>PxXT0Ui__Dxlw&kUc zZnnN1ua}n!zOn01K60_{)jy_8-xD2WRi+0#eE;j!t+2NnCIcFnvso_ztI zR5XJx{A+$Jwdl{?&0TXdE-qj{zT9b1+mbdL`NaqJ7cM$~$zaa=wWh%~3+D4qN^G(DdZ6^H-s;oypBoE(dE%b& zc4w;;(kItnjFP?Vn<(`>CfTvh81La%!RZ*2(d{`g+TI{^T9=cz%7G84sIoX@cDBUAb1O zwvVf)1^+r=P%?Y^eZTx%>6de3>h7K}EYaENchp?gKKoXv-{1Eide7$cO3m9Yb2>gD zqgQ#k{oi(MPkwhZ=0ySbvbmf&*OLjO#aCC9wNIQ`#UmKTa!UD0gG_H~)V z#UHvC^XISZDxVSjkwMD&{@eawiV*e6Q#-`{J|nO44Xx#Yw=rx~{jpU>bb%Cu8{A7Vn|I-G$0g}3S05SaP{Wd7HM^_2jQ5I@bp;O{Nnd)yzr$~4AfNBN z##(L-!>)%6Zw@MVW%V{N@&xrIaP+^Py`SZ5L+0AAGPygy-Ve|hI2E}1L%6S1`yGzX zN7hP<)@ZY7at6vQNw;y?)vno>AhS5US=C2Kt%jHVaP+@L(T6(NX0C~!HAD64Bl7}A z)$+D7UscQHFD_VV&RO)DUFne2?!>jzA7vzbe_4;(l>7Ba;@K(&28P=V3=B%xo35U) zWf71$QrF&tT!##J+Wwz;`AkG`4_DmRq=T>5Y4NZ)UaY=*>W0SK6_L+r>}mrtA4RlXHWnNzL;(sCBs8N#a@t$Kn3HPqb2R2kSkW zWbmthmcK01K}XpqLQw^}+jXyQO}@N&FY|x2OB4R9)^7^qU|?ty!tK+%;*!Li9N40} zx#5*HBH^-_Munq=cko#-W?f~WH*10`TM>1 z|Nj10_x|n8|2AI?8cKg0SK4yuk>bI_+!Y%tJw81+k>TuhC{fa}$J;{5*{w_5{pag% z3UMbscUL?=YO|-MJ=x}tqH^E#Q!fsmuGmp5`0mi@pRe;I*B{WH`6SdRuD80Wf_L&h zvyST<*IYkc!F=z8b^fXYdn=5E_q9&XU+ch|Kq{-$XkV+@)I{Qen0)>p}T60&mm4udGQriYoxMy zv=>P^S3Od@dx1r)ynj{u)eU*Am3uuGG-WQTy7cl&ZsXFMQ`hn@m$_^7{f1lL$t!a{ z%-g4zw5#Q$(zHsC{-rrnU$VT~rE4wLcQfnCg+q_G&GN`7t2i**`jcm;eaM<+^)9j7 z>zwPg^a9>$8iyE*y;Em0cH?18cky2rsl8#>lxcd#FITi3ESX&zT)vI#PN=nfVxoZ8 z+D-f7wgeZiU3Xji!TSx1M82xt-L>-TvPXw!?TwawW)o6g9QAG0wsWt~EStYqBw&}% zZ-JfyzJ!n+fyiY<*IG#EOvIin^@YDG0~87enIN_cQFc?kvD%XQSm8z z)G5mzhdG>In#%m zJbo-`nJgrykX^h?U3HGo4ijk~^TpQf?prxt$6Z-}+~=z2%_Zw<^&IChhR9BwlAFCu z_}v19O}m7?o%kDesxKpM!ls>>X+}(E%Z!sEJez|0pY=opa?TI>pr9t2bKIB9EV;XB zt&I)4xmLz;GgjFbAsd)8Crw@Nbx@>?bB#vZi)~Z#Tuh@6n)qo~aHMOjKYMLPlmF)` zcDuz763=M-(BYr|c<=eg++6!Q^fiAN?NfTVnrmO%X0CmG`5qN62hBgOXv$A?JG5Q2 zhW);%M!w&QZHpgN|FDys%F6I^U1F>Kgb&WF_DSpHAFB)1t$L9DV`o$GkJhI6M~1BZ zkHfj@&i<-zmPlL{Y;4kgle1o=@U)#*)4T)us(<_rzW>P1T7P7|$REji#YaL1R9s3U zG?k;0qx^hN?TPij62N%XLdgZJin|`pJgb*9?{@{qtDVQT?=kytb6cR(O)7Bm-dlpNa_cV7-Nbh10Go;Kk%onr z-tG=5|MJW8U$w@2Pjm4&>6_DL>mQm{b0ps9^l7b(RMy%5-luQT?vi<>7@5_>wI*qs zr>;p?jeAA7-+Hri(JL0lTjeVJ7FeR2&X7I2B) z^YUE0smhQyQ)lL)a{afzF6~HRl9s--Vx9wcF2}tueAE0MUz&F;Q)jlrgk?|F{S#Tw zPG2gh^lK{fybG^>EBx#~{QkC6_J5PGuj}R=xUolV&AFKup7H-QUUbAn@BDb@%PXWU{@tJ1^W>AstIHV)wO{<(duKKq`R86Pl*!T77m`(* zIImS_Pu1G(Us9*(vT}XCac!gPk`KFN99P(6xA#A~=)jry{LYj$C2N&Gt+8Xmejw`Gm+4$0*g+TA-}8NOMz{5`|GLv|aPnv;~zg@W6qM=!duh(Sd zp<^3%$O{&V1%BkPU3osy#fCA~rZe!Pe^2LUgIFK=CZAhj6Wi{#JP>aAVjB`$H|cJ} z-Y=pGp1(cs+Z{4pXx02T>Z1kQ%aDYaJKkHj&ade2tS;aVUBd6*nRTa&S>>sWRP4#8 zJ9>ABEc$+qFGwZiR%T<3+;0^Y)i1WaN2i)kitU?VP$PF*#YM$szT5E?Ezjy7PODe; z3Axa5*1w4_bZc-$Qru0&H`5eXv|YYYu_@)NmYn0U<~b5l?lD!ei{II>9k%TbZmD{) zde*n7hkw6b%(H)je%iyur-84knHU%(*>LtDJfW=;DD9k)nw$-3icOsYzM;JBe&+Vr zT{+y>^8YX__0n7#%{5ElsKjgmUkClHSr6`R;7Lu)JejxppK|_=3oeT;9`V-y6L`#c z^KJjEjLTon%(+`R|MR@LH~)VAeq5hH)y7P~-LGvaOY0t<(?+^4G)@&BbzoAJUCrZm z_Hp&PiklWclcIG%*PhJH^s+uE0dys*sZ_VRkGpG)338aGpqdex~=|F zAlm$M)jRW@ucz_1)GoQS>%%F%@3jw&b5hQ)7T+!vcb3a1Lf)pXNXbTfNlH~_h`YA= zw-qO@ObZrYAANlHq3YA+u}@be7H>ag>;CMa>)#!@QQxDt`Enkv+P432;i}{FtxA{w z>`AO+Fj}%wYi6#TMM&WsuC~YFtV zHzx{CcGc*VF{*M#$TJ&MnA}bXfWrj%Uo)_Dc^Swb-<;W4! z81spTRE5j>#@kx`6qPeI-Y)dUf}PggG*Mj+z>LE=H8^Me3XmJ zQv8HLb9BQjPm5sVn>j6YU!EK`($ARkebUbL7wVj^+c|d6yVRP|e|@Ul`jY}R)BhQ| zsLkK}lJiW^)rR!eJrXzj=JI{9xqZ0v;k+jke%L)b-+A)P17>Nxa}N#*6qcBLJbbRN z{OEnbogQ;S9<`oJ``&fa^-;q7&02a%QE%;PH*q*gqwn3{b+^Ifng|0!y)FZT0*+n; zBo#vv?%F8MnwZe5_Ugtn%_}SKURw6@LM!7ev+T=zOM<#GXI+|NqU$Su`P8RLdsLV= zADL;M%cK=BF*ZB`wxjTUazYHgUb{(sPy+t_v(nEdAn^zYaNPY%=eq z;=hlJtzO!AKkJ*Ww&F8q%_0-^mo~G#OLt9t?qMAM*l6yuf}LyDU)XW!e5lpk^H-(L zUw&z^?&3?vdHTxSPcj$U=w7PW=;t_Z;b)<^Rl&CUldn%+8mZ^IVgg^$+WFxbHC;aI zue?;QS;bR#;C$(&^_Oek)UQI0mbx9X#ref@>=YmGLB)0C|)P4RER`HLyOJ0esE&J@w^)miXV5Z%&kF8d& zKb7X{KfC*>q;1W=9ml3;{NbECX_Ml?>8IA6c6oHVw8}l_qV8Gin$~0CSv8`QFG_gd zN>d5CpSyMb^0?%%MRrMQlfx!yZrGLRdw1Hhv+3*ea&GQfS$K8Y*W4p4V-ndv?3q5VJ&Q9-3(zh_IgdjD#Zkk94Wk2=@QTGw(@ zaAQ`P>X~_R+JTpssBOG_U+}(Y_mQ)F%Odl*rNfs$e>z3(^1F4+-50lY1lV27J$&qr z>5Y;(S8vOCsvfppYx=Mw^!mL#)yc2V&RAKox^VG@f*ZUF&!YwVTHj9fNuRp6Kjyvu z$%!}h?w@+Hw!b{WM|jpD!_}6extoP%`}%aTK6;y$vU(=R>FTNr+2!qtJ0;htTW@3g zUR8a_C^x&*BGXv6d}_k%%ZC@teYh^<(3Zm=BXV~pe<|4GR-&{l1dvdy>uy8FJ{vgRngR0B7zsgpZjF}@#Hb@l}n4N zanU_5x0zc#yXe!a0;SVeHk@g*s;S=Les7`ogCnmd=6d$pvu;kq8Y|Stifbwo^H7*P6a=^m8aK z-ErFM)`F)mMI=Ph{d0T1F3FjbGBfbwwrzG1_qX^iW13KIkub}wZFOjuuwhAe?zZ{c z@4X4Mxcoz^*M99qxnBFjCZ)@#a+H62npF^N!e^%T zWyOr&?(15+M55gDX2iih4GWv-ZY#HD!82vnscs@6T$4VAEV>)S7nt$&Tg2OKR~M%J z5oDh%VEUqOdCbMnthy0aiN{YYm9p)1jye=;c-c?2;Kv{33sQ@D8B{ZG^6ot_PnYXX zhU%h+DM2<2b{4awSk4OT_?_;vUa-l^EHbTK*k^K?yG?5P$5nlC!u~G4x5K#i2(s8; zc$w*}a6a(aF_)M_pH_ufJPL4fTY55C{6vrGAz8DOz1z&P>$fg8Y*Q9qDW_pue$s;7 zbz$z6u2Y9M>b?7E*}IfQqTi`i_pUj33^vVZkD`d^sLR@g( zrz7*0`wLe8_-yfG#;UlRC3_viFHB{Nb(^03hqXEXfi&;F&irdX#42}QeQ^6nS@Zh` zw*#B^@!t~X*l5lBPw76ZV3DfJgpA1O@ZLiwALxIaE~r_QUOY!aL_z!g!}St>LigQ% zu>Rw7-hJ)+`x>6c9n1K`+r0necd0$o-y8p^t8bK8;~jjy$*axQtkl z%Jr{jou?X|xzo0-*f8fqon69*W}A!;&NcFvXSSy*)s_jlA8Ee$zn8gL{+YXpUB-vS ze*dn?>~Dl@rNbk)ZOT%Ib7RUYsmGK@5<#Z zn`Mx9oO9{MOA*=!-tJP%`o_BZ?A)*oXE!A|-tPJ4At-o9bldLdQ;%J_?y4lUWw)<% zujriDzup>{-@`)h4R)Dy}DHAB<0QH5Idtgp?Sr7r-PSLGIfLlnuF$^nqoX>Yijkb zb3B@>8+9EXm7cJbumBX^jI9~MU+q)MnywuKbQuXHfCs)H8 zvM>Jw|BrUI{~iqXSN9sFaP3gZPe1oS{+`k@vb}hV7hAAw4PqWOwo`l$Ac&GJ+;)B{IGeZmE{fN7!7vo z$DIKIkIrjL{Xg{}zB_SCMNu4&=;CAh4S#I^v+CG>;~(Guus8odQGe)*-tv7WKTQ8^ zKDhp~v_@v^X^rN4kIZ@hbxVBG{g>@_I5|tb`M=u6-|yM}H^-a)n4k1ca&hm*L>EH~ zU2~b|ex0)S#5Vsqy`cQ%eYX9F%b9!I{h54<`!~rs{hT{fY`NRsogZ%4zcStyu68r7$-Eckq~rJR^P4JpMf{3HlJ&8y8&m)0yxr%J^*uP|rq!?aHu*+1 zc3WOIsvghK$xb)l9xvK|yMS?Xx-HixvD-d3k{UUKzM0NkDbdw@#7bCs^PT+iw!JFa z=MR0^{&dAtcm3}3zYJapbrz=T?V7?Z_+0Ktr(bFA$qJR!=tu7tZ+r5q`|4|#KTJFS zL_Lnl^krf>-XZ9>?%b6f7na{-beUW7e0*BRXuzqf5`mN6aPQKE2aPrncMxs0`UYE!w?zM58t^v&A2iCgB|E~j+~ zk7AXxPuYcB4OIV>>m&Zjr?~IciCa9X>dxLt6RnTx>fVe`d+Kv#RmL@u`>h-Dy#Ab? zGjaE^uRQh7lzo+=&NcnYGrq{gzfoY#RPzeuRUR7WnkrT1Iz+C!=oYui_l4Qy*N-_@ zD<-)n%F2XFY_59y<#mQ}>f;W!wU*|Sc2>q*3}@?^DX7UCtyGk1ZkSmXHPh+h&8OY- zp5NKR-j-+B3JT^9cC3^CnhlQTGDn`d&htJTi3b6zs9MLo>_ zQ0TlWp?x+Ie|2KJ1s|r_zg)5~KJuo&@U_J=Z^V3L=skXT$prHUZG=z*0+rYH{@0tk_*U*?(5>9Cy(w?OY{i=;Cn~w`c7>{6;@QRPZ?{W!_udD zzBjS`{qY&{#@9uCZ~HfXZ7zw)e#+pR|4rws-z*RQpUyvz-rI9K!>v?d|NJzauVU5t zvQ?|KCcM{dn_&E%XK&P9|IObf2!1;0e4u>8s`-8VmF$`~tQ^!^KG}NL9^KdQeWG>d zKNTsiitgHOyKVFRcrqE+&iDIt&#$%dPv_^~ncucK?Bki5xBJNP>b7~)A3wXpt)}i^ zxxDFM@Jhe0a!a?Z4e2ps-B_07s=weeyYPJf z=7)FOPPk__V;lRix6DuU*lw&laKm(gj@J7YukH-KyRPv~MNA8Rrru%>{r2FecEA?5 z+l-6WCx!Wx$6hnJe(`RA$sMkceQw`1?&&XGf7$#%vGH8D`z)gRe(|2)FU4Kh&0j4x z*^FhAU9aAS6)uZ^n^rZL-g5u(Y}Qt{#dZJdGllM%a(z;qwK(waZj-zgO?C#)PaH?V%5y_)|*44d3Ck*SsP%RYtqhn}umb;Ez^_oQW9A77dIg!a$-B(tEhlBI)d z*7xAElOs&(uPDuoC|Liq4DaQ9O62y}KjBW~K{kmP#vr_1(fa!G?X-PPdb* zEzZ1J6MS&W)ys@8%=kmjEo9FWu(IZzRnM_Gu1ex_+od&UY?~}E-evhDvuJ@Ed%v~I z=7qLG`{ccUzcjY+I1?QIm*r|`2=9eM6Sham6kqJ!!XOd5XKhC3vLmZ`~ynxg6X@%|0QntePj?UVQ)PfAN<+ zkC_7&_;37YG3n?^^9kke6{c}ZnwcsoB6&WX!`wX%9Eqt|qOWd+g{AZcUvSpEP zUpvbm-M?2h@dgHl?Ack#f41O6-cjXGhwFYN*l#^`ploA+fTWZ364?bm7A`XW{Zgk< zaaWzy?)m3#a%Xl4uG3qtyK2AK!o!UZ%Vu?b^UV0I`Qp9glhf%NA6YtlsN}l%eZj@| z$#3}|=-4m%{NnhDefCHHO?P<7`sDij!&>@{x4JnZ_BT}j6WUQFnNY_X@OSH`4E_hZ zdY?63`abpa)TV3vg;C~bS&nuWJX5;Gvdf+^%BjR^#ebe{JB7028A|nENL5q?Hvavn z!zLGgu18AZ+$V0#T4}K$0x03Um3lreuw1FKQWH)7_Iz+I#m4y+inDM_NcB6eI&B+uSk1^;`}{d6y|mK z*GRmmPn#fAFgGRpfVsmrhNVtXt*hkA*tRgPHWR^K?wv$8fUN{MTKNC%ZRq%o0xGySU%5nq%Xu{TwHpL%KRP_I!R3 z@-2Y5I#ajkRhixbKfM+gwxbu8U-~@q@?uw zidxSSm#P0s+`e#2zG=er6#lh~gfeg2$7ub&A}HF`x?nR4Gat7U&pVCS=9_`;K3_Js z@6DWK`XW^?o@t%6RUb&1A_@G1A{#FF;SoVboheU*2&rVml8yd|L30`nwQ-3 z(BXz!v6gtO`JQe=Lltk250f?|Y$`v0YVx|xTh@s5#vk=x=$8J7+rB~mRK>g?$v#u> z%a@-Qo%>&|_wv2HeLZ8w0!eqC?MXYDZX1RK^e%4OWm3Lmr}o)xOrZz+nqu2toV+2R z$84PtQIQtW_Ir+EkmZVRFD`HT-K2QeQ0HTojfKcLJA0FtDLa$1@4P8IKB4-*!6P|I z<+h~bWhJ5I757D6vA6TL9}2YIwvK!GNddFmxr*U0Wj=q8G2OXzb?kLRoxPfkryP3R zV!m?}^YN@)cY15N{?7~QdLD(V&u=hZeckGjU-+D!)}@_krOzI?=BWfyNJb(Fu|xVY2SS#0WzFImyYR3#v@IDy=!oCWi{0sSfmc z7Ja~U{)Nw+GdUFv6T2UOgWaz#{*L!QN}mU9pW?9s0(_Y0u8gxmjCXU&*?m@25_X#Q6gUTg)cwEsH&4 zup{)QmtM-`#0^&-nA=P!+@SBg@uu!u&!ZFWO`7PlP4XG{Uf;bLKYI?|>|NcY7(QvD zMNa(GR;#B^Cm7XfuKVzzM|)+V`Ae13n@JKzTWzD3r|g`2!XoJd-y)9{cPDI*YKk)R z+PI3<^yun^9C@PoX02R(l|lCx{u1R4_O@@kz+3055+D3*PEF#^-)kdZEe^lXalrFV znCL~uy7QAv&wie8a80}24!KJOv1bg|{EwI8G}LmEyV|t4$T>uaZTEus!iVcKBbNC4 zTs$B3VJll#jc&&tUbkPHQhsyJ4Q}_f3$J0*UT1SqYn|P}I)jk&7i2b-Zkm+A%kxNx zc}nDq3mp86YL_0*?EK=?og{zwt|?<>thcCq;@x^a|JhHrO1>zS+QlsCR;POV{>k$F z6Lfo|tlMlVYgaXEUYhz7?MSW#54Tyfurn}d^I@Ek3tP00635UO4SQKHeO1&pSy%b= zW7ead8#W3b<=m)qQp}Tcip3I_ZJnYz)?eM`GMw{}Iz8FLIBD9Emwuet+`7MmyuMF+ zw@fvti@WCi{VShd?=?<7X;##zpZ~79_`dD)dF9W~{r<^XbW%Z;pJr^Cu_m*P4bCS5kkjnxl0x%FxuEam%9> z4c`o7jdde8i#?sRmg9wmy>)S3QqI18s~2aSXq~hvLF?F(xHNwAo>j}`LcFL<&3mg%_O|zl z-#wkytZ=9`up-uydj@id259FmtVtY!&6%&0HyyD^A+^UPS4gi7(ApuP9Z_i9$O zo^m)6&2&h_+_(Li%!vc{;e%dg^I`k(a7?=-Cx(V=sQm?z8&8=q~RwW6Qi5x2HYZ^XS{r9ol?#2j5S3I?`4q z_F|rFum0OBG39GA7rrlfHbs-a?GhJL+x`pb??c7S4ZZK5c~Wrs-hSN+i+=M4N*7yJ z2~R&P>uVyRZt18(Y(A!)-Mi6ff7XlZJGiZ) zwp~r7F;n=RH)?`PfGc{Xg9{&>)&v{Tf^sw7c-j;qX~ z>WhZEdVgMQ-d)JV^lo7!w~)#H^3ydEf_Wt=?mMnu3|>-x#Xn=u{50<;j~Az>?_pD% zx1)1f#V56z_tvxht#AJ^kz4RlAxhAd-LbwmY|%S2n+D^CWW$~Z5jHEl7!uq~w7W%D;JE2X(6Uw&k+ShTwB zwjY~Wt@7=f?>V1K*Q6u|7ck%Twr!l3z*~L&_0F$K9*-5CiYi}ozTv;9Z(>Wfhn1z@ zt~swQ?;WsE(^wp~Yo48l()J5_(&;BZUA$-XbH2jdV<)VgxmYy%T8dLxPfq@)F;7hG zY{cb*7J^^yAKIt&?RvrQ5@qq}|2BB!sQTSvsr#Q#S!+-g2z1(&roRR-K=dF{5)t<{E%uc!& ze<`tVRKGs???3NQ9@7io1CK3`VV*MIMY1f=Pw>>j!l=EaYsyDt7nd~Aj zUi{HACQ9*-xS-(l8GNzUHdg0@CB)u^C^@xE%2yQm&!GFd^F%@Iv*_o+pHFETIi)_i z5|Z_~c*pC>vMu^TH9IJ=HC$95%Sd?tJ{JSZ54%fa%Q`G;m zX`X$Y@mD^;o0)}+fq{dA0d$yUOa0l`hgcXGYB(7f46wJA(C;enNX$#gf$ez8e8WlRY#4li}ey`>$Ke6adXO}@V|9$CO`J6 z{c!f6ozmTcv>{C=mn?)jbK`sep%|NVX6{vXQ+rG4BB zgacWH8Lucv9gw&eU?ADxFO|%wQ4#ESZ_=$PO_{95TxrZ14p;guE%)gj-6QgmAwJJc@9UrQF5)g;>Z>_<_JqE&5fUR_zA zl9k|Yr^i=jl{s@SH#L^*v)-1drp(0qc-qWTlgp7;E?LGd_|}vtb#6w3`jyQZ4`jE8 zW!l=L&iLh%m+ZP-e9P1=dXpnLb@#5g*)~VKH%>h@h4t5R9cxLyTeq^J`15Z?sV%W3iBp*HtoDi^KRl}*$#$7umnwYAm8`pn%8Dsyss&U8E&B9fUOZwDHY699; zRWL4Ro%QY7%qyET*DY1*oO0k|mI&t-+l6P1<}OU)eXVuL=;K7KzhW8pS6A7kTODiL zcm8JB&ZXX#%6nJcW{M5Gz3t7pvs3cQmg(kB-FI$pTUlJX?qV&o1h2%ECmtwAPFSnN z?e6Gzls9M9j?O7vx-~i%e!uzNoUR@HiAU>)%hBaOe2zZfvGfsl&B{mGHESP9*Jxje zs4S~wIwhsK;GOyhL*Kk3PN%k+b(dG`Ydm|g?OBA*;%gdmb#^@AH+P;=R+i`X>^rd0 z_WHls8iM;=Zk;+6?)L7cN|{2*2bHff2BOx2D-Ve4zqtIN?zq;BiX~3$jV@opRwk_C z(#`w3wMPHZF6Hg}|My)G(R_UC=#O5Z%C)HDHNuit{RZ>5u|7(8{lqy#i z@t{WZhq3B`&m5oPc^~XtSl5*-o^G1)-2X&)(%Yq#d9Sx*_NHs5Y=VnI zxEa1In4T>A>w>>X^U?SZLY#lLb%H8mGiUxa{>xl> z<)iqGHmgTdy)IPOOV2gl9WcH8qQjSk&kk3b7`X<^on`)B@T9I@&b|DCl7aDt=CDTV z9X?Ba6OKrKc~-b)r)~f9cMJBLygc#vM~D84#ovUZel^)$FKxOfJLgM6=I@Js_hTzg zxSO*@{!lo#D_P*Mgrn_tWpn%f#Z!(yJkBX|%+q9NPMP5=lfQRW6ppOO+jFLH#%94F zVMR-Ci3Z>Ex|^zI&#XUS`N!$XdWoXWIZwh*X>JiPJt3v^LgAgx*4uARSyt8b)V0Rj z>Tl$mbn1j+U)>*{*9Nl%j(oT15BL1%u~C;bzU3C{_v(}X1$?>Of(t&%``9EbyMA|Cd{SFs`0hVw<>4yZmHZo+7#Qr?ajswthinRV z&d*EBOfM};M06N?C+GSLI|>|6U-;$LV=wRdEHzA`nfwozaB(SddW8CjIgE*(C$XKEkYMkVukc$eY>_+tV8Gjs`e)O!*{^-O z#&*s4?pM=yw|O=N`gia4UEOE8UGw|i=LZ#=oyu3$ozVWw{q>w7^V`+MLcWVMUiIo* zuM=Kl@IrrTxx|sUE&Lnit+QPBcU|$F4SV_bT;%$m_x|%WzV9+o-6=X2B{ghMqi$zM z`S#0i$$8y<+}|(f{H@+U-P?Yj%q+9<)9IL1EdXtdf%OPdyseN?*%je{|g6ykzmK$2W^#)O}Z4<#O27Rp-^) zzU}$@OJ2Nl4;8oZUT(O6UOfwZdq$e!a4F3N!RN?qhH zG4N|C{8X(W?;730xTUfqamnKeJZaqqZT1ce3It->rC&(8Z9k$WR<(+EhP}i8GCLhT z7V$@o#UFDe#2<;idAB&P_?O5RZV}rvehZ%YSuB(&?c@}>xc9;_;TwgbDr(I>T8{4y z*@}AjuxTzn_J+}k-&xvU%h5zK#f`Hlo_Ennh2j+}zc8HSnxLtAVgZlQdKS%VUv)GVzW(n(~PVMEj*jLW^I*IsAM!+%i?)TAyoAQv#CcA+f9eFv*i?6 z8I6Rq(*C0jo4Eg3!7s|mz#t>Sz@UM>DF99St`(`trHEr>!@t)HKbQPJPxt4O#}P(s zIXr^L8YblMuyANExiRC>HRr849g1r$z0-d?T+>`G{6J*Z#Y-hiv~y=ocvo^^<_xhM zDf5NGw@Tjc{hj{g^0&%+)=MT^X#912Up4Rh?>EnF_kMaW9?!Oc_5Y-g9X;;O6+Vul z_p~18ay3~jcQ|zW=aWLo_Z$!B8r@s^fal)Y1!DdhGun%d?k!GGJa<*-d39xlJpcRW z-SQ6}dM>LoXyO0(M`V7@jBx+<4?O3eS5}y{^ndshs8-(-F3*}M$0Pe=AHCEv13Jo^}bV2 z)@yn^7HW;E)FZ@e*dHB3JzRO(q z%X%xRYkhCt7F}iQebI;h_!-aMzI9El$7zug&fZi8+2505p|XT5Au*L8B6CKP#Ht-EZ( z#k)6S4wm(mpZ?L-Gd*RG&pY|+x$CQzNY9wik;8gBjxn(m9;^Y!%Xm*nYf z^Eo#CTH-9Tm&>14nQrsmoO4&GkSpVwMP&4I<>n~{G9H}1+l4g}Pp;tqr+ey9xG2|z z!(|udsLXyo@%NpCfZR3R_txBDv#$-^p0xaziQCPG#qa$lUivj7?8KcHG1l)nUUuk* zoVX;f6R>dkN{v1bj(I5_lHr=`H8%R5erNirGD-L5Y86*8>#aMMEK)Mt#KC#?|L(FA zT=^TlI>J6!-!?1Xz46Ms5S~Y~Rc@`{HS?RUTw6$u^!PQMJxnDbxAZYve@NAATJw zb09lZ#$`KKZ>eq5Vy$(Lf2*jx)x655yMFZ##<;+V$2~>uQ#?~;IM2=t_^ABshoSK6 zy+@w3xakLOGFbUYo$pwtsC}y0S4HLNOEg~ewahpC`aG3|{fD^E4^`n;HM>+RH(b_9 zxpc_;&LXAdH8NQVC`K`6e zk$3t$*Dp6Ox2f%S{o{C4{-e5R{WBkllQvQ3EKR(x^??*tIoIg%U=Dpl06wU zV|`76nEUB?i)X7=SF~AtUeUF9-=bHa>ds6Rn6udYv(@s`+7;bf9hUsj_2jy1mGnfW z(CgmrLJhth-i2lxZ*RY!w4_x$i&JgptP4L`XPke}QEQ!&yPR3fUefD+!B^LBOD50O z+~ttSb8Fd(=A%>P)Ba=>sZN?2c%Ps78OJgswcA%JUZ+pa@e$mwYnS74>(0yT2YtUxK{NB^wW(FxOA9CaeX!B{}K$70rnXA`f!nl({mPmr+hmjo-PrN3V~HTW${ z-W=g@fSa@8VyaHx);Ko{vHd-}EHaDb{EtPiPw;#r6qpuh-@G(QGVa)Ao@wV7C9?IN zyUF!@jpv-TCFjB{)5|BeD;Y<;l`*q!&t^&PF5Y&{GFtuIwRnf*`2~mBj`J3lIp5Yv z3fG_XPtc|{U7%FKO;3;&Ovjd1!uQ~CveD$8w+Ww$$v_>b1IzdP>dlO!&8 zG;i1b=>p##*t|Q+E%LvmAhF?_cOgf6tM~Gk#|#w0-gtPfv2b41;yr!QD|?n8--D9_ zRWI)ko3*&))Owp%Wbi_+QNUj7*5aahS{ zZp#k&Kb#kRJ_+}^94q?D^+?aA!F*EO8efh}t23sZd}n@9&tbXpk=ivYPBK5@QDS-c zVVPvkK0Z~kdnVnw?avFoZ-~g7dvx2WGp|%de+9U6O)<*ey1Hq0WU1UtuKE`{jOME; zMQ#X%1UOwLG%d%x!%2n~F>lR&k60&w_fR2J^;-pajV&${G zPhzeqE0sjQOtV;TVbHUMWwA%4bI5tcM?X$@6nA`?wP;uJm&N+*+xfO%ICHIaPRO0m z$m7RtMT2fDwX=5dbqLM1+ZV(9 zZO2WiuA@sCM01w%TKbE%bqP6Zbm*P(P>)^0+!FFuVe2o&oAVUz^&D9pyk(WpjODRs zo@a=QY;}?JY292IzCf{n*Oa&M<+W^%ZGP)*`fo+d5vq#PQY|!2Y%~z@2`sPZs3~mI zvFLtUF=Nt8!A17ZJ|=qyPM@|&Pj{W(o>@vP-;$N@`J^*WOyOD;?;3GzW`b_vT-_>T z$ILU^XSpcvHh*EZ=^?}Qf4$q{9!@`QE0?GB>e7=}N)L6L{y+TB6yVLsB*KgsXFJ_#nKbu>sS-g2bZ4+|-iPBHg^i+|*(sjpYZK2gaZPJjspX|KyxZjKI5k$9|m+ z69YpqE9BTNuwDiRh9!+ZFf}_S6_*qxCYLzp=jJBnrTAp#r8?*5m8F7i!owbh*PFL) zj$mP6c*DiOU=1@HY9;- zf0!8ONUtISd@;(rVtiyyH!jK z3`f||J*8|)giTN@$`gxH33_UV>Vq@8nHU&Wu`w{1qlBM^1<_U!ilirL&NG%XGB7M= zhAcIK*|Vh4!kTDH2zq7FvG1;{nHU(hv7+ZBQ(K~KfuuU@2_W6EaMKhf28Ie&^k^}3 zAkhLsA(r4<7s|rKz_5dbfx!qCT;NQr;7o*71jDM}Yy|IJCI*IVc1WuUX2p`m5?3P3 z56#OaA>#-vwq+L+VqnP8gsehAShmiCuw~feLaO_z-AQ%^1_mK?GdZF$%mfEV3bYso z)#BLAth~=!a*K(9!IhPPK?mkYaG;AO6E+i)>#&;~Q+CV=bk8|AGXwbUdYIu$8ZFZ> zO(y;(d-T$f>pbdx>Ii#yGcfEypRq&=9|F}l$XHmz4s^#ih!1HkKgdL}rWADJH@YK1 zs$sf8H-3Zo5ZwYj6Q40r$hU!`n~8oaGQ!OETok84o56VR zNJck26Ya8HgyFsUL>rDhePy8CE{QPudkI#fVaI#nYW|?#<%lrwRw;f1U5L5a5j|+o z?`cC=ms^G3I$XE5q1%9dEfm6rggU%7U=Q$0w9|hP#+{ge*SG+zH(jE;7X7eYgz-Bj zVl|%7(YxptpdVw1uwefTtQJ7}kk}42L^m4!bUB34&9ku@jds=?x_RhlxFO8TorB#x z$f*;!k~aG3D+u$Lm*O=K^DGv0r=lN;fiU{ya(qT(Ph;qd$q}aA-GkLMj|u8qStFED{;_Wj6Rc% xF#YIRtfoVUQLxNwqZ^GrQ;0D7zy*9pqs|)!c(byBbec1mGbjr)Fi775@c;|Mb6fxb literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4f1e707 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sat Aug 01 23:25:05 CEST 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 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. +# + +############################################################################## +# +# 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/master/subprojects/plugins/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 + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# 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"' + +# 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 + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# 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 + which java >/dev/null 2>&1 || 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 + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + 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" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + 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 + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@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 + +@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=. +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%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="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! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kotlin-js-store/wasm/yarn.lock b/kotlin-js-store/wasm/yarn.lock new file mode 100644 index 0000000..4c1723e --- /dev/null +++ b/kotlin-js-store/wasm/yarn.lock @@ -0,0 +1,13 @@ +# 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== + +ws@8.20.1: + version "8.20.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb" + integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== diff --git a/server/build.gradle.kts b/server/build.gradle.kts new file mode 100644 index 0000000..1e715e5 --- /dev/null +++ b/server/build.gradle.kts @@ -0,0 +1,75 @@ +plugins { + alias(libs.plugins.jvm) + alias(libs.plugins.ktor) + alias(libs.plugins.serialization) +} + +group = "com.jaytux.phoebench" +//version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.exposed.core) + implementation(libs.exposed.dao) + implementation(libs.exposed.jdbc) + implementation(libs.exposed.migration) + implementation(libs.exposed.migration.jdbc) + implementation(libs.exposed.kotlin.datetime) + implementation(libs.kotlinx.datetime) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.logging) + + implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.openapi) + implementation(libs.ktor.server.auto.head.response) + implementation(libs.ktor.server.netty) + implementation(libs.ktor.server.config.yaml) + implementation(libs.ktor.server.test.host) + implementation(libs.ktor.server.auth) + implementation(libs.ktor.server.auth.jwt) + implementation(libs.ktor.server.call.logging) + implementation(libs.ktor.server.cors) + implementation(libs.ktor.server.status.pages) + + implementation(libs.ktor.serialization.kotlinx.json) + + implementation(libs.dotenv) + implementation(libs.json) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization) + implementation(libs.kotlinx.serialization.json) + implementation(libs.ksoup) + implementation(libs.slf4j.simple) + implementation(libs.mariadb) + implementation(libs.sqlite) + implementation(libs.spring.security.core) + + implementation(project(":common")) +} + +tasks.test { + useJUnitPlatform() +} +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xcontext-parameters") + optIn.add("kotlin.uuid.ExperimentalUuidApi") + } +} + +application { + mainClass.set("com.jaytux.phoebench.server.MainKt") +} + +tasks.withType { + manifest { + attributes["Main-Class"] = application.mainClass + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/Auth.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/Auth.kt new file mode 100644 index 0000000..5d457a5 --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/Auth.kt @@ -0,0 +1,41 @@ +package com.jaytux.phoebench.server + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import io.ktor.server.application.Application +import io.ktor.server.auth.jwt.JWTAuthenticationProvider +import kotlinx.datetime.toInstant +import kotlin.time.toJavaInstant +import kotlin.uuid.Uuid + +object Auth { + private lateinit var _secret: String + private lateinit var _issuer: String + private lateinit var _audience: String + private lateinit var _realm: String + + context(app: Application) + fun setup() { + if(Auth::_secret.isInitialized) throw IllegalStateException("Repeat initialization") + _secret = app.environment.config.property("ktor.jwt.secret").getString() + _issuer = app.environment.config.property("ktor.jwt.issuer").getString() + _audience = app.environment.config.property("ktor.jwt.audience").getString() + _realm = app.environment.config.property("ktor.jwt.realm").getString() + } + + context(conf: JWTAuthenticationProvider.Config) + fun installRealmVerifier() { + conf.realm = _realm + conf.verifier(JWT.require(Algorithm.HMAC256(_secret)).withAudience(_audience).withIssuer(_issuer).build()) + } + + fun generate(userId: Uuid): String { + if(!Auth::_secret.isInitialized) throw IllegalStateException("Auth helper has not been initialized yet") + + val access = JWT.create().withAudience(_audience).withIssuer(_issuer) + .withClaim(com.jaytux.phoebench.common.Auth.JWT_CLAIM, userId.toString()) + .withExpiresAt(nowPlusMinutes(5).toJavaInstant()) + .sign(Algorithm.HMAC256(_secret)) + return access + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/DotEnv.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/DotEnv.kt new file mode 100644 index 0000000..c8f8d7a --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/DotEnv.kt @@ -0,0 +1,17 @@ +package com.jaytux.phoebench.server + +import io.github.cdimascio.dotenv.dotenv + +object DotEnv { + val env by lazy { dotenv() } + operator fun get(name: String) = env[name] ?: throw DotEnvException.missingVariable(name) + fun getOrNull(name: String): String? = env[name] + fun getOrDefault(name: String, default: String): String = env[name] ?: default + + class DotEnvException(message: String) : Exception(message) { + companion object { + fun missingVariable(name: String) = + DotEnvException("Missing required environment variable: $name") + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/Main.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/Main.kt new file mode 100644 index 0000000..b2a90da --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/Main.kt @@ -0,0 +1,169 @@ +package com.jaytux.phoebench.server + +import com.jaytux.phoebench.common.ErrorResponse +import com.jaytux.phoebench.common.Routes +import com.jaytux.phoebench.server.db.DB +import com.jaytux.phoebench.server.db.User +import com.jaytux.phoebench.server.handlers.AuthHandler +import com.jaytux.phoebench.server.handlers.ProjectHandler +import com.jaytux.phoebench.server.handlers.deleteAdmin +import com.jaytux.phoebench.server.handlers.deleteAuth +import com.jaytux.phoebench.server.handlers.get +import com.jaytux.phoebench.server.handlers.getAdmin +import com.jaytux.phoebench.server.handlers.getAuth +import com.jaytux.phoebench.server.handlers.patchAdmin +import com.jaytux.phoebench.server.handlers.patchAuth +import com.jaytux.phoebench.server.handlers.post +import com.jaytux.phoebench.server.handlers.postAdmin +import com.jaytux.phoebench.server.handlers.postAuth +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.autohead.* +import io.ktor.server.plugins.calllogging.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.plugins.cors.routing.CORS +import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.json.Json +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import java.net.URI +import kotlin.uuid.Uuid + +fun main(args: Array) { + DB.db + EngineMain.main(args) +} + +fun Application.module() { + install(ContentNegotiation) { + json() + } + + install(StatusPages) { + status(HttpStatusCode.Forbidden) { call, status -> + call.respond(status, ErrorResponse("Access Forbidden: CORS failed.")) + } + status(HttpStatusCode.Unauthorized) { call, status -> + call.respond(status, ErrorResponse("Unauthorized/unauthenticated.")) + } + } + + val allowLocalhost = environment.config.propertyOrNull("ktor.cors.enableLocalhostOn")?.getString() ?: "0" + val safeOrigin = environment.config.propertyOrNull("ktor.cors.browserOrigin")?.getString() + install(CORS) { + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Delete) + allowMethod(HttpMethod.Patch) + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) + allowHeader(HttpHeaders.Authorization) + allowHeader(HttpHeaders.ContentType) + exposeHeader(HttpHeaders.ContentType) + allowCredentials = true + allowNonSimpleContentTypes = true + + if(allowLocalhost != "0") { + val hostPort = allowLocalhost.toIntOrNull() + if(hostPort == null) { + println("Config error: disabling localhost CORS ('$allowLocalhost' is not a valid port number)") + } + else { + println("Config: localhost (http://localhost and http://127.0.0.1) CORS is allowed on port $hostPort!") + allowHost("localhost:$hostPort", schemes = listOf("http")) + allowHost("127.0.0.1:$hostPort", schemes = listOf("http")) + } + } + if(safeOrigin != null) { + val host = safeOrigin.removePrefix("http://").removePrefix("https://").trimEnd('/') + println("Config: allowing CORS on host $host; scheme=https") + allowHost(host, listOf("https")) + } + } + + install(AutoHeadResponse) + install(CallLogging) + + + Auth.setup() + authentication { + jwt("auth-jwt") { + Auth.installRealmVerifier() + validate { credential -> + val claimString = credential.payload.getClaim(com.jaytux.phoebench.common.Auth.JWT_CLAIM).asString() + if(claimString != "") { + val found = Uuid.parseOrNull(claimString)?.let { transaction { User.findById(it) } } + + if(found == null) null + else JWTPrincipal(credential.payload) + } + else null + } + + challenge { defaultScheme, realm -> +// call.respond(HttpStatusCode.Unauthorized, ErrorResponse("Invalid/expired token")) + call.respondText(Json.encodeToString(ErrorResponse("Invalid/expired token")), ContentType.Application.Json, HttpStatusCode.Unauthorized) + } + } + } + + routing { + get(Routes.handshake, AuthHandler::handshake) + post(Routes.Auth.login, AuthHandler::login) + post(Routes.Auth.signup, AuthHandler::register) + post(Routes.Auth.refresh, AuthHandler::refresh) + + authenticate("auth-jwt") { + postAuth(Routes.Auth.logout, AuthHandler::logout) + postAuth(Routes.Auth.logoutEverywhere, AuthHandler::logoutEverywhere) + + postAdmin(Routes.Auth.Invite.new, AuthHandler::newInvite) + getAdmin(Routes.Auth.Invite.list, AuthHandler::listInvites) + deleteAdmin(Routes.Auth.Invite.delete, AuthHandler::deleteInvite) + + getAdmin(Routes.Auth.User.list, AuthHandler::listUsers) + patchAdmin(Routes.Auth.User.update, AuthHandler::updateUser) + deleteAdmin(Routes.Auth.User.delete, AuthHandler::deleteUser) + + getAuth(Routes.home, ProjectHandler::home) + + postAuth(Routes.Project.new, ProjectHandler::createProject) + getAuth(Routes.Project.get, ProjectHandler::getProject) + patchAuth(Routes.Project.update, ProjectHandler::updateProject) + deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject) + + postAuth(Routes.Label.new, ProjectHandler::createLabel) + patchAuth(Routes.Label.update, ProjectHandler::updateLabel) + deleteAuth(Routes.Label.delete, ProjectHandler::deleteLabel) + + postAuth(Routes.Entry.new, ProjectHandler::createEntry) + patchAuth(Routes.Entry.update, ProjectHandler::updateEntry) + deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry) + } + + get("{...}") { + println("Fallback for GET ${call.request.path()} triggered.") + call.respond(HttpStatusCode.NotFound, ErrorResponse("Could not find ${call.request.path()}")) + } + + post("{...}") { + println("Fallback for POST ${call.request.path()} triggered.") + call.respond(HttpStatusCode.NotFound, ErrorResponse("Could not find ${call.request.path()}")) + } + + delete("{...}") { + println("Fallback for DELETE ${call.request.path()} triggered.") + call.respond(HttpStatusCode.NotFound, ErrorResponse("Could not find ${call.request.path()}")) + } + + patch("{...}") { + println("Fallback for PATCH ${call.request.path()} triggered.") + call.respond(HttpStatusCode.NotFound, ErrorResponse("Could not find ${call.request.path()}")) + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/Util.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/Util.kt new file mode 100644 index 0000000..294cd71 --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/Util.kt @@ -0,0 +1,24 @@ +package com.jaytux.phoebench.server + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.format.MonthNames +import kotlinx.datetime.format.Padding +import kotlinx.datetime.format.char +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Instant + +val systemTZ = TimeZone.currentSystemDefault() + +fun nowPlusMinutes(min: Int): Instant = nowPlus(min, DateTimeUnit.MINUTE) +fun nowPlusDays(days: Int): Instant = nowPlus(days, DateTimeUnit.DAY) + +fun nowPlus(time: Int, unit: DateTimeUnit): Instant { + val now = Clock.System.now() + return now.plus(time, unit, systemTZ) +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/db/ArrayColumn.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/db/ArrayColumn.kt new file mode 100644 index 0000000..f2f3d6e --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/db/ArrayColumn.kt @@ -0,0 +1,15 @@ +package com.jaytux.phoebench.server.db + +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table + +inline fun Table.list(name: String): Column> { + val ser = serializer>() + val json = Json + return text(name, eagerLoading = true).transform( + wrap = { json.decodeFromString(ser, it) }, + unwrap = { json.encodeToString(ser, it) } + ) +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/db/DB.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/db/DB.kt new file mode 100644 index 0000000..05992cc --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/db/DB.kt @@ -0,0 +1,33 @@ +package com.jaytux.phoebench.server.db + +import com.jaytux.phoebench.server.DotEnv +import io.github.cdimascio.dotenv.Dotenv +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils + +object DB { + val db by lazy { + val conn = Database.connect( + url = DotEnv["DATABASE_URL"], driver = DotEnv["DATABASE_DRIVER"], + user = DotEnv.getOrNull("DATABASE_USER") ?: "", + password = DotEnv.getOrNull("DATABASE_PASSWORD") ?: "" + ) + + transaction { + SchemaUtils.create(Users, Invites, RefreshTokens, Projects, Labels, Entries) + + val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Labels, Entries) + try { + migration.forEach { + exec(it) + } + } + catch(e: Exception) { + println("Migration failed: exception ${e.message}") + println("Continuing anyway...") + } + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/db/Entities.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/db/Entities.kt new file mode 100644 index 0000000..1ae9826 --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/db/Entities.kt @@ -0,0 +1,71 @@ +package com.jaytux.phoebench.server.db + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.Entity +import org.jetbrains.exposed.v1.dao.EntityClass +import kotlin.uuid.Uuid + +class User(id: EntityID) : Entity(id) { + companion object : EntityClass(Users) + + var username by Users.username + var password by Users.password + var isAdmin by Users.isAdmin + var isOwner by Users.isOwner + var projectLimit by Users.projectLimit + + val projects by Project referrersOn Projects.ownerId + val sessions by RefreshToken referrersOn RefreshTokens.userId +} + +class Invite(id: EntityID) : Entity(id) { + companion object : EntityClass(Invites) + + var expires by Invites.expires + var inviteAsAdmin by Invites.inviteAsAdmin +} + +class RefreshToken(id: EntityID) : Entity(id) { + companion object : EntityClass(RefreshTokens) + + var userId by RefreshTokens.userId + var expires by RefreshTokens.expires + + var user by User referencedOn RefreshTokens.userId +} + +class Project(id: EntityID) : Entity(id) { + companion object : EntityClass(Projects) + + var ownerId by Projects.ownerId + var name by Projects.name + var isPublic by Projects.isPublic + + var owner by User referencedOn Projects.ownerId + val labels by Label referrersOn Labels.projectId + val entries by Entry referrersOn Entries.projectId +} + +class Label(id: EntityID) : Entity(id) { + companion object : EntityClass(Labels) + + var label by Labels.label + var color by Labels.color + var projectId by Labels.projectId + + var project by Project referencedOn Labels.projectId +} + +class Entry(id: EntityID) : Entity(id) { + companion object : EntityClass(Entries) + + var projectId by Entries.projectId + var labelId by Entries.labelId + var timestamp by Entries.timestamp + var warmups by Entries.warmups + var measurements by Entries.measurements + var unit by Entries.unit + + var project by Project referencedOn Entries.projectId + var label by Label referencedOn Entries.labelId +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/db/Tables.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/db/Tables.kt new file mode 100644 index 0000000..43ef35c --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/db/Tables.kt @@ -0,0 +1,50 @@ +package com.jaytux.phoebench.server.db + +import com.jaytux.phoebench.common.TimeUnit +import org.jetbrains.exposed.v1.core.ReferenceOption +import org.jetbrains.exposed.v1.core.dao.id.UuidTable +import org.jetbrains.exposed.v1.datetime.timestamp +import kotlin.time.Clock + +object Users : UuidTable() { + val username = varchar("username", 255).uniqueIndex() + val password = varchar("password", 255) + val isAdmin = bool("is_admin").default(false) + val isOwner = bool("is_owner").default(false) + val projectLimit = integer("project_limit").default(1) +} + +object Invites : UuidTable() { + val expires = timestamp("expires") + val inviteAsAdmin = bool("invite_as_admin").default(false) +} + +object RefreshTokens : UuidTable() { + val userId = reference("user_id", Users) + val expires = timestamp("expires") +} + +object Projects : UuidTable() { + val ownerId = reference("owner_id", Users, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) + val name = varchar("name", 255) + val isPublic = bool("is_public").default(false) + + init { + uniqueIndex(ownerId, name) + } +} + +object Labels : UuidTable() { + val label = varchar("label", 255) + val color = varchar("color", 7) + val projectId = reference("project_id", Projects, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) +} + +object Entries : UuidTable() { + val projectId = reference("project_id", Projects, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) + val labelId = reference("label", Labels, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) + val timestamp = timestamp("timestamp").default(Clock.System.now()) + val warmups = list("warmups") + val measurements = list("measurements") + val unit = enumeration("unit") +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/AuthHandler.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/AuthHandler.kt new file mode 100644 index 0000000..48b5343 --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/AuthHandler.kt @@ -0,0 +1,202 @@ +package com.jaytux.phoebench.server.handlers + +import com.jaytux.phoebench.common.* +import com.jaytux.phoebench.server.Auth +import com.jaytux.phoebench.server.db.Invite +import com.jaytux.phoebench.server.db.RefreshToken +import com.jaytux.phoebench.server.db.RefreshTokens +import com.jaytux.phoebench.server.db.User +import com.jaytux.phoebench.server.db.Users +import com.jaytux.phoebench.server.handlers.RouteError.Companion.success +import com.jaytux.phoebench.server.nowPlus +import com.jaytux.phoebench.server.nowPlusDays +import com.jaytux.phoebench.server.nowPlusMinutes +import com.jaytux.phoebench.server.systemTZ +import io.ktor.http.HttpStatusCode +import io.ktor.util.logging.KtorSimpleLogger +import kotlinx.datetime.DateTimeUnit +import org.jetbrains.exposed.v1.core.Transaction +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import kotlin.time.Clock +import kotlin.uuid.Uuid + +object AuthHandler { + private var _hasOwner = transaction { + User.find { Users.isOwner eq true }.count() != 0L + }.also { + if(!it) { + val invite = transaction { + Invite.new { + expires = nowPlusMinutes(5) + inviteAsAdmin = true + } + } + println("No owner yet. Use the below invite code to create an owner account (expires ${invite.expires}):") + println(invite.id.value) + } + } + + internal val logger = KtorSimpleLogger("AuthRoutes") + + fun Transaction.newRefreshToken(userId: User): RefreshToken { + val token = RefreshToken.new { + this.user = userId + this.expires = nowPlusDays(15) + } + logger.info("Created refresh token for (${if (userId.isAdmin) "admin" else "regular"}) user ${userId.username}; expires at ${token.expires}\nWith refresh token ${token.id.value}") + return token + } + + suspend fun register(req: SignupRequest) = transaction { + val invite = Invite.findById(req.invite) ?: throw RouteError( + "Invalid invite code", + status = HttpStatusCode.Unauthorized + ) + if (invite.expires < Clock.System.now()) { + throw RouteError("Invite has expired", status = HttpStatusCode.Unauthorized) + } + val inviteAdmin = invite.inviteAsAdmin + + invite.delete() + val user = User.new { + username = req.name + password = Bcrypt.encode(req.pass) + isAdmin = inviteAdmin + if(inviteAdmin) projectLimit = -1 + } + if(inviteAdmin && !_hasOwner) { + user.isOwner = true + _hasOwner = true + } + logger.info("New user: ${user.username} (${user.id.value}; is admin? ${user.isAdmin})") + + val access = Auth.generate(user.id.value) + val refresh = newRefreshToken(user) + success(TokenResponse(access, refresh.id.value)) + } + + suspend fun login(req: LoginRequest): Pair { + val invalidUser = + { throw RouteError("Login error: invalid username and/or password.", HttpStatusCode.Forbidden) } + return transaction { + val user = User.find { + Users.username eq req.name + }.firstOrNull() ?: invalidUser() + + if (!Bcrypt.verifyAgainst(req.pass, user.password)) invalidUser() + + val access = Auth.generate(user.id.value) + val refresh = newRefreshToken(user) + success(TokenResponse(access, refresh.id.value)) + } + } + + suspend fun refresh(req: RefreshRequest) = transaction { + val token = RefreshToken.findById(req.refreshToken) + try { + logger.debug( + "Received refresh request with token {}; found as {} for {}, expires at {}", + req.refreshToken, + token?.id, + token?.user?.username, + token?.expires + ) + if (token == null || token.expires < Clock.System.now()) { + logger.debug( + "Token: {}; found as {} for {}; expires at {} (now is {})", + req.refreshToken, + token?.id, + token?.user?.username, + token?.expires, + Clock.System.now() + ) + throw RouteError.unauthorized("Invalid or expired refresh token ${req.refreshToken}.") + } + val user = token.user + + val access = Auth.generate(user.id.value) + val refresh = newRefreshToken(user) + token.delete() + success(TokenResponse(access = access, refresh = refresh.id.value)) + } + catch(re: RouteError) { + throw re + } + catch(e: Exception) { + token?.delete() + throw RouteError("Invalid refresh token.", HttpStatusCode.Unauthorized) + } + } + + suspend fun logout(user: User, req: LogoutRequest) = transaction { + val ok = success(EmptyResponse()) + val token = RefreshToken.findById(req.refresh) ?: return@transaction ok + if (token.user.id == user.id) { + token.delete() + logger.info("Deleted refresh token ${token.id.value} for user ${token.user.username}") + } + ok + } + + suspend fun logoutEverywhere(user: User, req: EmptyRequest) = transaction { + RefreshTokens.deleteWhere { RefreshTokens.userId eq user.id } + success(EmptyResponse()) + } + + suspend fun newInvite(user: User, req: InviteRequest) = transaction { + val invite = Invite.new { + inviteAsAdmin = if(user.isOwner) req.asAdmin else false + expires = nowPlus(48, DateTimeUnit.HOUR) + } + success(UuidResponse(invite.id.value)) + } + + suspend fun listInvites(user: User, req: EmptyRequest) = transaction { + success(InviteListResponse(Invite.all().map { + InviteListResponse.Invite(it.id.value, it.expires, it.inviteAsAdmin) + })) + } + + suspend fun deleteInvite(user: User, req: Uuid) = transaction { + val inv = Invite.findById(req) ?: throw RouteError("Invalid invite code", HttpStatusCode.NotFound) + inv.delete() + success(EmptyResponse()) + } + + suspend fun listUsers(user: User, req: EmptyRequest) = transaction { + success(UserListResponse(User.all().map { + UserListResponse.UserData(it.id.value, it.username, it.isAdmin, it.projectLimit, it.projects.count().toInt()) + })) + } + + suspend fun updateUser(user: User, req: Pair) = transaction { + val target = User.findById(req.first) ?: throw RouteError("Invalid user ID.", HttpStatusCode.NotFound) + val changes = req.second + + changes.projectLimit?.let { + if(!target.isAdmin || user.isOwner) target.projectLimit = it + else throw RouteError("Only the server owner can modify admin project limits.", HttpStatusCode.Forbidden) + } + changes.isAdmin?.let { + if(target.isOwner) throw RouteError("Admin-status of the server owner cannot be changed.", HttpStatusCode.Forbidden) + if(user.isOwner) target.isAdmin = it + else throw RouteError("Only the server owner can change admin status.", HttpStatusCode.Forbidden) + } + success(EmptyResponse()) + } + + suspend fun deleteUser(user: User, req: Uuid) = transaction { + val target = User.findById(req) ?: throw RouteError("Invalid user ID.", HttpStatusCode.NotFound) + if(target.isOwner) throw RouteError("The server owner's account cannot be deleted.", HttpStatusCode.Forbidden) + if(!target.isAdmin || user.isOwner) { + RefreshTokens.deleteWhere { RefreshTokens.userId eq target.id.value } + target.delete() + } + else throw RouteError("Only the owner can delete admin accounts.", HttpStatusCode.Forbidden) + success(EmptyResponse()) + } + + suspend fun handshake(req: EmptyRequest) = success(HandshakeResponse()) +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/Bcrypt.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/Bcrypt.kt new file mode 100644 index 0000000..06be4af --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/Bcrypt.kt @@ -0,0 +1,10 @@ +package com.jaytux.phoebench.server.handlers + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder + +object Bcrypt { + private val enc = BCryptPasswordEncoder() + + fun encode(password: String): String = enc.encode(password)!! // can only be null if input is null + fun verifyAgainst(raw: String, reference: String) = enc.matches(raw, reference) +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/Bridge.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/Bridge.kt new file mode 100644 index 0000000..36bcc4f --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/Bridge.kt @@ -0,0 +1,143 @@ +package com.jaytux.phoebench.server.handlers + +import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrapped +import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAdmin +import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAuth +import com.jaytux.phoebench.common.ApiRoute +import com.jaytux.phoebench.common.Elevation +import com.jaytux.phoebench.common.EmptyRequest +import com.jaytux.phoebench.server.db.User +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.plugins.ContentTransformationException +import io.ktor.server.request.receive +import io.ktor.server.routing.Route +import io.ktor.server.routing.RoutingContext +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.delete +import io.ktor.server.routing.patch +import io.ktor.util.reflect.typeInfo + +suspend inline fun ApiRoute.paramArgs(call: ApplicationCall): TReq = + parseParams(call.parameters) ?: throw RouteError( + "Missing or malformed parameters for ${this.verb} ${this.pattern}", + HttpStatusCode.BadRequest + ) + +suspend inline fun ApiRoute.queryArgs(call: ApplicationCall): TReq = + parseQuery(call.request.queryParameters) + +suspend inline fun ApiRoute.bodyArgs(call: ApplicationCall): TReq = try { + call.receive(TReq::class) +} catch(_: ContentTransformationException) { + throw RouteError("Malformed request body for ${this.verb} ${this.pattern}", HttpStatusCode.BadRequest) +} + +suspend inline fun ApiRoute.nonBodyArgs(call: ApplicationCall): TReq = + parseNonBody(call.request.queryParameters, call.parameters) ?: + throw RouteError("Missing or malformed parameters for ${this.verb} ${this.pattern}", HttpStatusCode.BadRequest) + +suspend inline fun ApiRoute.nonQueryArgs(call: ApplicationCall): TReq { + return parseNonQuery(call.parameters) { type, urlParam -> + try { + if(TReq::class != Pair::class) + throw RouteError( + "Invalid request type for route ${this.verb} ${this.pattern}", + HttpStatusCode.InternalServerError + ) + + println("Bridge: non-query args with url param ${urlParam::class.simpleName} and body param ${type.simpleName}; TReq is ${TReq::class.simpleName} ~ ${typeInfo()}") + (urlParam to call.receive(type)) as TReq // I know this is hacky, but ... yea, I would also prefer C++ templates... + } + catch(_: ContentTransformationException) { + throw RouteError("Malformed request body for ${this.verb} ${this.pattern}", HttpStatusCode.BadRequest) + } + } ?: throw RouteError("Missing or malformed parameters for ${this.verb} ${this.pattern}", HttpStatusCode.BadRequest) +} + +inline fun ApiRoute.args(): suspend (ApplicationCall) -> TReq { + if(TReq::class == EmptyRequest::class) return { EmptyRequest() as TReq } + return when(bodySource) { + ApiRoute.ReqBodySource.BODY -> { call: ApplicationCall -> bodyArgs(call) } + ApiRoute.ReqBodySource.PARAMS -> { call: ApplicationCall -> paramArgs(call) } + ApiRoute.ReqBodySource.QUERY -> { call: ApplicationCall -> queryArgs(call) } + ApiRoute.ReqBodySource.NON_BODY -> { call: ApplicationCall -> nonBodyArgs(call) } + ApiRoute.ReqBodySource.NON_QUERY -> { call: ApplicationCall -> nonQueryArgs(call) } + } +} + +inline fun Route.wrapper( + api: ApiRoute, selector: Route.(String, suspend RoutingContext.() -> Unit) -> Route, + crossinline wrapper: suspend (TReq) -> Pair +): Route { + if(api.elevation != Elevation.UN_AUTH) throw IllegalArgumentException("${api.verb} ${api.pattern} can only be used with ${api.elevation}") + val getter = api.args() + return selector(api.pattern) { + wrapped { + wrapper(getter(call)) + } + } +} + +inline fun Route.wrapperAuth( + api: ApiRoute, selector: Route.(String, suspend RoutingContext.() -> Unit) -> Route, + crossinline wrapper: suspend (User, TReq) -> Pair +): Route { + if(api.elevation != Elevation.AUTH) throw IllegalArgumentException("${api.verb} ${api.pattern} can only be used with ${api.elevation}") + val getter = api.args() + return selector(api.pattern) { + wrappedAuth { user -> + wrapper(user, getter(call)) + } + } +} + +inline fun Route.wrapperAdmin( + api: ApiRoute, selector: Route.(String, suspend RoutingContext.() -> Unit) -> Route, + crossinline wrapper: suspend (User, TReq) -> Pair +): Route { + if(api.elevation != Elevation.ADMIN) throw IllegalArgumentException("${api.verb} ${api.pattern} can only be used with ${api.elevation}") + val getter = api.args() + return selector(api.pattern) { + wrappedAdmin { user -> + wrapper(user, getter(call)) + } + } +} + +inline fun Route.get(api: ApiRoute, noinline handler: suspend (TReq) -> Pair): Route = + wrapper(api, Route::get, handler) + +inline fun Route.getAuth(api: ApiRoute, noinline handler: suspend (User, TReq) -> Pair): Route = + wrapperAuth(api, Route::get, handler) + +inline fun Route.getAdmin(api: ApiRoute, noinline handler: suspend (User, TReq) -> Pair): Route = + wrapperAdmin(api, Route::get, handler) + +inline fun Route.post(api: ApiRoute, noinline handler: suspend (TReq) -> Pair): Route = + wrapper(api, Route::post, handler) + +inline fun Route.postAuth(api: ApiRoute, noinline handler: suspend (User, TReq) -> Pair): Route = + wrapperAuth(api, Route::post, handler) + +inline fun Route.postAdmin(api: ApiRoute, noinline handler: suspend (User, TReq) -> Pair): Route = + wrapperAdmin(api, Route::post, handler) + +inline fun Route.delete(api: ApiRoute, noinline handler: suspend (TReq) -> Pair): Route = + wrapper(api, Route::delete, handler) + +inline fun Route.deleteAuth(api: ApiRoute, noinline handler: suspend (User, TReq) -> Pair): Route = + wrapperAuth(api, Route::delete, handler) + +inline fun Route.deleteAdmin(api: ApiRoute, noinline handler: suspend (User, TReq) -> Pair): Route = + wrapperAdmin(api, Route::delete, handler) + +inline fun Route.patch(api: ApiRoute, noinline handler: suspend (TReq) -> Pair): Route = + wrapper(api, Route::patch, handler) + +inline fun Route.patchAuth(api: ApiRoute, noinline handler: suspend (User, TReq) -> Pair): Route = + wrapperAuth(api, Route::patch, handler) + +inline fun Route.patchAdmin(api: ApiRoute, noinline handler: suspend (User, TReq) -> Pair): Route = + wrapperAdmin(api, Route::patch, handler) \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/ProjectHandler.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/ProjectHandler.kt new file mode 100644 index 0000000..657432c --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/ProjectHandler.kt @@ -0,0 +1,170 @@ +package com.jaytux.phoebench.server.handlers + +import com.jaytux.phoebench.common.EmptyRequest +import com.jaytux.phoebench.common.EmptyResponse +import com.jaytux.phoebench.common.EntryRequest +import com.jaytux.phoebench.common.EntryResponse +import com.jaytux.phoebench.common.HomeResponse +import com.jaytux.phoebench.common.LabelRequest +import com.jaytux.phoebench.common.LabelResponse +import com.jaytux.phoebench.common.NamedID +import com.jaytux.phoebench.common.PartialEntryRequest +import com.jaytux.phoebench.common.PartialLabelRequest +import com.jaytux.phoebench.common.PartialProjectRequest +import com.jaytux.phoebench.common.ProjectRequest +import com.jaytux.phoebench.common.ProjectResponse +import com.jaytux.phoebench.server.db.Entries +import com.jaytux.phoebench.server.db.Entry +import com.jaytux.phoebench.server.db.Label +import com.jaytux.phoebench.server.db.Labels +import com.jaytux.phoebench.server.db.Project +import com.jaytux.phoebench.server.db.Projects +import com.jaytux.phoebench.server.db.User +import com.jaytux.phoebench.server.handlers.RouteError.Companion.success +import io.ktor.http.HttpStatusCode +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Transaction +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import kotlin.uuid.Uuid + +object ProjectHandler { + context(trns: Transaction) + private fun Project.isEditableBy(user: User): Boolean = ownerId.value == user.id.value + + context(trns: Transaction) + private fun Project.isAccessible(user: User, forEditing: Boolean): Project { + return when { + isEditableBy(user) -> this + isPublic && !forEditing -> this + else -> throw RouteError("Invalid project ID.", HttpStatusCode.NotFound) + } + } + + private fun Transaction.accessibleProject(user: User, id: Uuid, forEditing: Boolean): Project { + val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound) + return project.isAccessible(user, forEditing) + } + + context(trns: Transaction) + private fun Project.toResponse(user: User) = ProjectResponse( + id.value, name, NamedID(owner.username, owner.id.value), isPublic, isEditableBy(user), + labels.orderBy(Labels.label to SortOrder.ASC).map { LabelResponse(it.id.value, it.label, it.color) }, + entries.orderBy(Entries.timestamp to SortOrder.ASC).map { EntryResponse(it.id.value, it.label.id.value, it.timestamp, it.warmups, it.measurements, it.unit) }) + + fun home(user: User, req: EmptyRequest) = transaction { + val own = user.projects.orderBy(Projects.name to SortOrder.ASC).map { + HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value)) + } + + val publics = Project.find { Projects.isPublic eq true }.orderBy(Projects.name to SortOrder.ASC).map { + HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value)) + } + + success(HomeResponse(user.username, user.isAdmin, user.projectLimit, own, publics)) + } + + fun createProject(user: User, req: ProjectRequest) = transaction { + if(user.projectLimit != -1 && (user.projectLimit >= user.projects.count())) + throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict) + + val proj = Project.new { + name = req.name + isPublic = req.isPublic + owner = user + } + + success(proj.toResponse(user)) + } + + fun getProject(user: User, req: Uuid) = transaction { + val proj = accessibleProject(user, req, false) + success(proj.toResponse(user)) + } + + fun updateProject(user: User, req: Pair) = transaction { + val proj = accessibleProject(user, req.first, true) + val changes = req.second + changes.name?.let { proj.name = it } + changes.isPublic?.let { proj.isPublic = it } + success(EmptyResponse()) + } + + fun deleteProject(user: User, req: Uuid) = transaction { + accessibleProject(user, req, true).delete() + success(EmptyResponse()) + } + + fun createLabel(user: User, req: LabelRequest) = transaction { + val proj = accessibleProject(user, req.projectId, true) + if(req.color.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest) + val lbl = Label.new { + label = req.name + color = req.color + project = proj + } + success(LabelResponse(lbl.id.value, lbl.label, lbl.color)) + } + + fun updateLabel(user: User, req: Pair) = transaction { + val lbl = Label.findById(req.first) ?: throw RouteError("Invalid label ID.", HttpStatusCode.NotFound) + lbl.project.isAccessible(user, true) + val changes = req.second + changes.name?.let { lbl.label = it } + changes.color?.let { + if(it.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest) + lbl.color = it + } + success(EmptyResponse()) + } + + fun deleteLabel(user: User, req: Uuid) = transaction { + val lbl = Label.findById(req) ?: throw RouteError("Invalid label ID.", HttpStatusCode.NotFound) + lbl.project.isAccessible(user, true) + lbl.delete() + success(EmptyResponse()) + } + + fun createEntry(user: User, req: EntryRequest) = transaction { + val proj = accessibleProject(user, req.projectId, true) + val entry = Entry.new { + label = when(val l = Label.findById(req.label)) { + null -> throw RouteError("Invalid label ID.", HttpStatusCode.NotFound) + is Label if l.projectId.value != proj.id.value -> throw RouteError("Label is attached to a different project.", HttpStatusCode.Conflict) + else -> l + } + project = proj + measurements = req.measurements + timestamp = req.timestamp + warmups = req.warmups + unit = req.unit + } + success(EntryResponse(entry.id.value, entry.label.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit)) + } + + fun updateEntry(user: User, req: Pair) = transaction { + val entry = Entry.findById(req.first) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound) + entry.project.isAccessible(user, true) + val changes = req.second + changes.label?.let { + val lbl = Label.findById(it) + when { + lbl == null -> throw RouteError("Invalid label ID.", HttpStatusCode.NotFound) + lbl.projectId.value != entry.projectId.value -> throw RouteError("Label is attached to a different project.", HttpStatusCode.Conflict) + else -> entry.label = lbl + } + } + changes.timestamp?.let { entry.timestamp = it } + changes.warmups?.let { entry.warmups = it } + changes.measurements?.let { entry.measurements = it } + changes.unit?.let { entry.unit = it } + success(EmptyResponse()) + } + + fun deleteEntry(user: User, req: Uuid) = transaction { + val entry = Entry.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound) + entry.project.isAccessible(user, true) + entry.delete() + success(EmptyResponse()) + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/RouteError.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/RouteError.kt new file mode 100644 index 0000000..f3510b5 --- /dev/null +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/RouteError.kt @@ -0,0 +1,78 @@ +package com.jaytux.phoebench.server.handlers + +import com.jaytux.phoebench.common.Auth +import com.jaytux.phoebench.common.ErrorResponse +import com.jaytux.phoebench.server.db.User +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.server.auth.principal +import io.ktor.server.plugins.BadRequestException +import io.ktor.server.response.respond +import io.ktor.server.response.respondText +import io.ktor.server.routing.RoutingCall +import io.ktor.server.routing.RoutingContext +import io.ktor.util.logging.KtorSimpleLogger +import kotlinx.serialization.json.Json +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import kotlin.uuid.Uuid + +open class RouteError(message: String, val status: HttpStatusCode = HttpStatusCode.InternalServerError) : Exception(message) { + companion object { + val logger = KtorSimpleLogger("RouteError") + + suspend inline fun RoutingCall.respondJson(status: HttpStatusCode, body: R) { + respondText( + text = Json.encodeToString(body), + contentType = ContentType.Application.Json, + status = status + ) + } + + suspend inline fun RoutingContext.wrapped(block: suspend RoutingContext.() -> Pair) { + try { + val (status, x) = block() + call.respond(status, x) + } + catch(e: RouteError) { + logger.info("Route error: ${e.message}") + call.respondJson(e.status, ErrorResponse(e.message ?: "Unknown error")) + } + catch(e: BadRequestException) { + logger.info("Bad request exception: ${e.message}") + call.respondJson(HttpStatusCode.BadRequest, ErrorResponse("The request sent was not properly formed.")) + } + catch(e: Exception) { + logger.error("Unhandled exception in route", e) + e.printStackTrace() + call.respondJson(HttpStatusCode.InternalServerError, ErrorResponse("The server could not process your request due to an internal error.")) + } + } + + suspend inline fun RoutingContext.wrappedAuth(block: suspend RoutingContext.(user: User) -> Pair) = wrapped { + val principal = call.principal() + val userId = principal?.payload?.getClaim(Auth.JWT_CLAIM)?.asString() ?: + throw RouteError("Missing user claim", HttpStatusCode.Unauthorized) + + val user = transaction { + User.findById(Uuid.parse(userId)) ?: + throw RouteError("User not found", HttpStatusCode.Unauthorized) + } + + block(user) + } + + suspend inline fun RoutingContext.wrappedAdmin(block: suspend RoutingContext.(admin: User) -> Pair) = wrappedAuth { user -> + if(!user.isAdmin) { + throw RouteError("Admin access required", HttpStatusCode.Forbidden) + } + block(user) + } + + fun success(data: R, status: HttpStatusCode = HttpStatusCode.OK): Pair = + Pair(status, data) + + fun unauthorized(message: String): Nothing = + throw RouteError(message, HttpStatusCode.Unauthorized) + } +} \ No newline at end of file diff --git a/server/src/main/resources/application.conf b/server/src/main/resources/application.conf new file mode 100644 index 0000000..aacf71b --- /dev/null +++ b/server/src/main/resources/application.conf @@ -0,0 +1,20 @@ +ktor { + deployment { + port = ${PORT} + } + application { + modules = [com.jaytux.phoebench.server.MainKt.module] + } + jwt { + secret = ${JWT_SECRET} + issuer = ${JWT_ISSUER} + audience = ${JWT_AUDIENCE} + realm = ${JWT_REALM} + } + cors { + enableLocalhostOn = ${PHOEBENCH_DEV_CLIENT_PORT} + browserOrigin = ${?PHOEBENCH_SAFE_CLIENT} + } + + development = false +} \ No newline at end of file diff --git a/server/src/main/resources/simplelogger.properties b/server/src/main/resources/simplelogger.properties new file mode 100644 index 0000000..e67eb96 --- /dev/null +++ b/server/src/main/resources/simplelogger.properties @@ -0,0 +1 @@ +org.slf4j.simpleLogger.log.io.ktor.server.plugins.cors.CORS=trace \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..e5bd4e1 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,34 @@ +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = "PhoeBench" +include("server", "clients", "common") \ No newline at end of file