commit 2a1487037b0e621b3e26d03828beb656341bfafa Author: jay-tux Date: Tue Aug 4 12:12:25 2026 +0200 Initial version (Server, Compose clients work) 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 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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