Initial version (Server, Compose clients work)

This commit is contained in:
2026-08-04 12:12:25 +02:00
commit 2a1487037b
59 changed files with 4701 additions and 0 deletions
+49
View File
@@ -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/
+14
View File
@@ -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"
+102
View File
@@ -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]
}
}
}
@@ -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)
}
@@ -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<String?>(null)
private val _protocolVersion = mutableStateOf<String?>(null)
private val _refresh = mutableStateOf<Uuid?>(null)
private val _access = mutableStateOf<String?>(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.")
}
}
@@ -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 <TReq: Any, TRes: Any> callRoute(using: HttpClient, route: ApiRoute<TReq, TRes>, body: TReq, wasInternal: Boolean = false): Either<ErrorResponse, TRes> {
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 <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> =
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")
}
}
@@ -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<T> {
fun load(): T?
fun save(value: T)
fun erase()
}
fun refreshToken(): IStoredProperty<Uuid>
fun server(): IStoredProperty<String>
}
expect fun persistentStore(): IStore
expect fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient
expect suspend fun String.toClipEntry(): ClipEntry
@@ -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<String>(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 <reified T> Either<ErrorResponse, T>.snackOnError() =
fold({ get().send(it.msg) }) {}
inline fun <reified T> Either<ErrorResponse, T>.snackOr(process: (T) -> Unit) =
fold({ get().send(it.msg) }) { process(it) }
suspend inline fun <reified T> Either<ErrorResponse, T>.snackOrSuspend(process: suspend (T) -> Unit) =
foldSuspend({ get().send(it.msg) }) { process(it) }
suspend inline fun <reified T> Either<ErrorResponse, T>.snackMaybeSuspend(): T? =
foldSuspend({ get().send(it.msg); null }) { it }
}
}
@@ -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 <T> MutableState<T>.immutable(): State<T> = this
fun <T> T.ignore() {}
inline fun <reified R> 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> T.nonEq(other: T) = if(this == other) null else this
inline fun <reified E, reified T> Either<E, T>.ignoreValue(): Either<E, Unit> = 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<Instant, Instant> {
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<Instant, Instant>.fmtRange() = "Between ${first.fmt()} and ${second.fmt()}"
infix fun Instant.inRange(range: Pair<Instant, Instant>) = 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()
}
@@ -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<String?>(null)
private val _isAdmin = mutableStateOf(false)
private val _projectLimit = mutableStateOf(0)
private val _ownProjects = mutableStateOf(listOf<HomeResponse.ProjectSummary>())
private val _publicProjects = mutableStateOf(listOf<HomeResponse.ProjectSummary>())
private val _users = mutableStateOf(listOf<UserListResponse.UserData>())
private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>())
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()
}
}
}
}
@@ -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<ErrorResponse, HomeResponse>
suspend fun logout(refresh: Uuid): Either<ErrorResponse, Unit>
suspend fun logoutEverywhere(): Either<ErrorResponse, Unit>
suspend fun inviteList(): Either<ErrorResponse, InviteListResponse>
suspend fun newInvite(isAdmin: Boolean): Either<ErrorResponse, UuidResponse>
suspend fun deleteInvite(id: Uuid): Either<ErrorResponse, Unit>
suspend fun userList(): Either<ErrorResponse, UserListResponse>
suspend fun updateUser(id: Uuid, isAdmin: Boolean? = null, projectLimit: Int? = null): Either<ErrorResponse, Unit>
suspend fun deleteUser(id: Uuid): Either<ErrorResponse, Unit>
suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit>
companion object {
class Default(private val _client: Client) : IHomeRepo {
override suspend fun getHome(): Either<ErrorResponse, HomeResponse> =
_client.callRoute(Routes.home, EmptyRequest())
override suspend fun logout(refresh: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Auth.logout, LogoutRequest(refresh)).ignoreValue()
override suspend fun logoutEverywhere(): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Auth.logoutEverywhere, EmptyRequest()).ignoreValue()
override suspend fun inviteList(): Either<ErrorResponse, InviteListResponse> =
_client.callRoute(Routes.Auth.Invite.list, EmptyRequest())
override suspend fun newInvite(isAdmin: Boolean): Either<ErrorResponse, UuidResponse> =
_client.callRoute(Routes.Auth.Invite.new, InviteRequest(isAdmin))
override suspend fun deleteInvite(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Auth.Invite.delete, id).ignoreValue()
override suspend fun userList(): Either<ErrorResponse, UserListResponse> =
_client.callRoute(Routes.Auth.User.list, EmptyRequest())
override suspend fun updateUser(id: Uuid, isAdmin: Boolean?, projectLimit: Int?): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Auth.User.update, id to UserUpdateRequest(projectLimit, isAdmin)).ignoreValue()
override suspend fun deleteUser(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Auth.User.delete, id).ignoreValue()
override suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.new, ProjectRequest(name, isPublic)).ignoreValue()
}
fun default(client: Client) = Default(client)
}
}
@@ -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<ErrorResponse, ProjectResponse>
suspend fun update(name: String? = null, isPublic: Boolean? = null): Either<ErrorResponse, Unit>
suspend fun delete(): Either<ErrorResponse, Unit>
suspend fun newLabel(name: String, color: Color): Either<ErrorResponse, LabelResponse>
suspend fun updateLabel(id: Uuid, name: String? = null, color: Color? = null): Either<ErrorResponse, Unit>
suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit>
suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
unit: TimeUnit): Either<ErrorResponse, EntryResponse>
suspend fun updateEntry(id: Uuid, label: Uuid? = null, timestamp: Instant? = null, warmups: List<Float>? = null,
measurements: List<Float>? = null, unit: TimeUnit? = null): Either<ErrorResponse, Unit>
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
companion object {
class Default(private val _client: Client, private val _projectId: Uuid) : IProjectRepo {
override suspend fun get(): Either<ErrorResponse, ProjectResponse> =
_client.callRoute(Routes.Project.get, _projectId)
override suspend fun update(name: String?, isPublic: Boolean?): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.update, _projectId to PartialProjectRequest(name, isPublic)).ignoreValue()
override suspend fun delete(): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.delete, _projectId).ignoreValue()
override suspend fun newLabel(name: String, color: Color): Either<ErrorResponse, LabelResponse> =
_client.callRoute(Routes.Label.new, LabelRequest(name, color.hexString(), _projectId))
override suspend fun updateLabel(id: Uuid, name: String?, color: Color?): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Label.update, id to PartialLabelRequest(name, color?.hexString())).ignoreValue()
override suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Label.delete, id).ignoreValue()
override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>,
measurements: List<Float>, unit: TimeUnit
): Either<ErrorResponse, EntryResponse> =
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit))
override suspend fun updateEntry(id: Uuid, label: Uuid?, timestamp: Instant?,
warmups: List<Float>?, measurements: List<Float>?,
unit: TimeUnit?
): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Entry.update,
id to PartialEntryRequest(label, timestamp, null, warmups, measurements, unit)
).ignoreValue()
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Entry.delete, id).ignoreValue()
}
fun default(client: Client, projectId: Uuid) = Default(client, projectId)
}
}
@@ -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<T> {
private val _internal = mutableStateMapOf<T, Unit>()
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<T>) {
_internal.putAll(elements.map { it to Unit })
_revision.value++
}
fun removeAll(elements: Collection<T>) {
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<T> = _internal.keys
}
fun <T> mutableStateSetOf(vararg elements: T): MutableStateSet<T> = mutableStateSetFrom(elements.toList())
fun <T> mutableStateSetFrom(coll: Collection<T>): MutableStateSet<T> {
val res = MutableStateSet<T>()
res.addAll(coll)
return res
}
@@ -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), "<invalid>", _errColor.hexString(), _errColor)
}
}
data class Entry(val id: Uuid, val label: Label, val timeStamp: Instant, val warmups: List<Float>,
val measurements: List<Float>, val nativeUnit: TimeUnit) {
companion object {
fun fromResponse(it: EntryResponse, map: Map<Uuid, Label>) = Entry(
it.id, map[it.labelId] ?: Label.invalid, it.timestamp,
it.warmups, it.measurements, it.unit
)
}
}
private val _name = mutableStateOf<String?>(null)
private val _owner = mutableStateOf<String?>(null)
private val _public = mutableStateOf(false)
private val _editable = mutableStateOf(false)
private val _labels = mutableStateOf(mapOf<Uuid, Label>())
private val _entries = mutableStateOf(listOf<Entry>())
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<Float>, measurements: List<Float>, 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<Float>?, measurements: List<Float>?, 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 }
}
}
}
}
@@ -0,0 +1,5 @@
package com.jaytux.phoebench.clients.theme
import androidx.compose.ui.graphics.Color
val linkColor = Color(0xFF64B5F6)
@@ -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<String?>(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<Uuid?>(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<NamedID>() }
val possibleUsers = remember { mutableStateListOf<NamedID>() }
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<UserListResponse.UserData?>(null) }
var deleting by remember { mutableStateOf<UserListResponse.UserData?>(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}")
}
}
}
}
@@ -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()
}
}
@@ -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<Uuid, ProjectVM.Label>,
onCancel: () -> Unit, onAddLbl: (name: String, color: Color) -> Unit,
onAdd: (label: ProjectVM.Label, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit) -> Unit
) {
Dialog(onDismissRequest = onCancel) {
var label by remember { mutableStateOf<ProjectVM.Label?>(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<Either<String, List<Float>>>(listOf<Float>().value()) }
var measureParsed by remember { mutableStateOf<Either<String, List<Float>>>(listOf<Float>().value()) }
fun parse(text: String): Either<String, List<Float>> {
val parts = text.split(',')
val parsed = ArrayList<Float>(parts.size)
val invalids = mutableListOf<String>()
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<Pair<List<DefaultPoint<Float, Float>>, 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,
)
}
@@ -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)
@@ -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()
}
}
@@ -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<T>(
val key: String,
val toString: (T) -> String, val fromString: (String) -> T
) : IStore.IStoredProperty<T> {
constructor(key: String, serializer: KSerializer<T>) : 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<Uuid> =
StoredProperty("refresh_token", serializer<Uuid>())
override fun server(): IStore.IStoredProperty<String> =
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))
@@ -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()
}
}
@@ -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<T>(
val key: String,
val toString: (T) -> String, val fromString: (String) -> T
) : IStore.IStoredProperty<T> {
constructor(key: String, serializer: KSerializer<T>) : 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<Uuid> =
StoredProperty("refresh_token", serializer<Uuid>())
override fun server(): IStore.IStoredProperty<String> =
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)
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PhoeBench</title>
<link type="text/css" rel="stylesheet" href="styles.css">
<script type="application/javascript" src="composeApp.js"></script>
</head>
<body>
</body>
</html>
@@ -0,0 +1,7 @@
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
+81
View File
@@ -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<Exec>("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<Task>("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")
}
}
+95
View File
@@ -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<KtClass>().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 <output-dir> <input-file>+")
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)
}
@@ -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<TReq, TRes: Any>(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<ErrorResponse, TRes> = 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<TRes>(_resType).value()
else body.body<ErrorResponse>().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<ErrorResponse, TRes> = 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<TRes: Any>(path: String, elevation: Elevation, resType: TypeInfo)
: ApiRoute<EmptyRequest, TRes>("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<TReq: Any, TRes: Any>(
path: String, elevation: Elevation, resType: TypeInfo, val urlEncode: (TReq) -> String,
val urlDecode: (String?) -> TReq?
) : ApiRoute<TReq, TRes>("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<TReq: Any, TRes: Any>(path: String, elevation: Elevation, private val _reqType: TypeInfo, resType: TypeInfo)
: ApiRoute<TReq, TRes>("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<TReq: Any, TBody: Any, TRes: Any>(
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<Pair<TReq, TBody>, 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<TReq, TBody>): 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<TReq, TBody>?): Pair<TReq, TBody>? {
val param = urlDecode(params["param"]) ?: return null
return receiver(_bodyType.type, param)
}
}
class DeleteRoute1<TReq: Any, TRes: Any>(
path: String, elevation: Elevation, resType: TypeInfo, val urlEncode: (TReq) -> String,
val urlDecode: (String?) -> TReq?
) : ApiRoute<TReq, TRes>("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<TReq: Any, TBody: Any, TRes: Any>(
path: String, elevation: Elevation, resType: TypeInfo, private val _bodyType: TypeInfo,
val urlEncode: (TReq) -> String, val urlDecode: (String?) -> TReq?
) : ApiRoute<Pair<TReq, TBody>, 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<TReq, TBody>): 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<TReq, TBody>?): Pair<TReq, TBody>? {
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 <reified TRes: Any> get(path: String, elevation: Elevation) =
GetRoute<TRes>(path, elevation, typeInfo<TRes>())
/**
* Builds a GET route with one request parameter (encoded as URL parameter in the endpoint, like
* `/endpoint/arg`).
*/
inline fun <reified TReq: Any, reified TRes: Any> get1(path: String, elevation: Elevation,
noinline encode: (TReq) -> String = { it.toString() }, noinline decode: (String?) -> TReq?
) = GetRoute1<TReq, TRes>(path, elevation, typeInfo<TRes>(), encode, decode)
/**
* Builds a GET route with one UUID request parameter (encoded as URL parameter in the endpoint, like
* `/endpoint/arg`).
*/
inline fun <reified TRes: Any> getUuid(path: String, elevation: Elevation) =
get1<Uuid, TRes>(path, elevation, Uuid::toString, this::parseUuid)
/**
* Builds a POST route with request and response types.
*/
inline fun <reified TReq: Any, reified TRes: Any> post(path: String, elevation: Elevation) =
PostRoute<TReq, TRes>(path, elevation, typeInfo<TReq>(), typeInfo<TRes>())
/**
* 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 <reified TReq: Any, reified TBody: Any, reified TRes: Any> post1(
pathPre: String, pathPost: String, elevation: Elevation, noinline encode: (TReq) -> String,
noinline decode: (String?) -> TReq?
) = PostRoute1<TReq, TBody, TRes>(pathPre, pathPost, elevation, typeInfo<TBody>(), typeInfo<TRes>(), 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 <reified TBody: Any, reified TRes: Any> postUuid(pathPre: String, pathPost: String, elevation: Elevation) =
post1<Uuid, TBody, TRes>(pathPre, pathPost, elevation, Uuid::toString, this::parseUuid)
/**
* Builds a POST route with request type and no response (EmptyResponse).
*/
inline fun <reified TReq: Any> postNoRes(path: String, elevation: Elevation) =
post<TReq, EmptyResponse>(path, elevation)
/**
* Builds a DELETE route with one request parameter (encoded as URL parameter in the endpoint, like
* `/endpoint/arg`).
*/
inline fun <reified TReq: Any, reified TRes: Any> delete1(path: String, elevation: Elevation,
noinline encode: (TReq) -> String = { it.toString() }, noinline decode: (String?) -> TReq?
) = DeleteRoute1<TReq, TRes>(path, elevation, typeInfo<TRes>(), encode, decode)
/**
* Builds a DELETE route with one UUID request parameter (encoded as URL parameter in the endpoint, like
* `/endpoint/arg`).
*/
inline fun <reified TRes: Any> deleteUuid(path: String, elevation: Elevation) =
delete1<Uuid, TRes>(path, elevation, Uuid::toString, this::parseUuid)
/**
* Builds a DELETE route with one request parameter and no response (EmptyResponse).
*/
inline fun <reified TReq: Any> delete1NoRes(path: String, elevation: Elevation,
noinline encode: (TReq) -> String = { it.toString() }, noinline decode: (String?) -> TReq?
) = delete1<TReq, EmptyResponse>(path, elevation, encode, decode)
/**
* Builds a DELETE route with one UUID request parameter and no response (EmptyResponse).
*/
fun deleteUuidNoRes(path: String, elevation: Elevation) =
delete1NoRes<Uuid>(path, elevation, Uuid::toString, this::parseUuid)
/**
* Builds a PATCH route with one request parameter and a request body.
*/
inline fun <reified TReq: Any, reified TBody: Any, reified TRes: Any> patch1(
path: String, elevation: Elevation, noinline encode: (TReq) -> String, noinline decode: (String?) -> TReq?
) = PatchRoute1<TReq, TBody, TRes>(path, elevation, typeInfo<TRes>(), typeInfo<TBody>(), encode, decode)
/**
* Builds a PATCH route with a UUID request parameter and a request body.
*/
inline fun <reified TBody: Any, reified TRes: Any> patchUuid(path: String, elevation: Elevation) =
patch1<Uuid, TBody, TRes>(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 <reified TBody: Any> patchUuidNoRes(path: String, elevation: Elevation) =
patchUuid<TBody, EmptyResponse>(path, elevation)
}
}
@@ -0,0 +1,5 @@
package com.jaytux.phoebench.common
object Auth {
const val JWT_CLAIM = "pb-user-id"
}
@@ -0,0 +1,27 @@
package com.jaytux.phoebench.common
sealed class Either<out E, out V> {
class Error<E>(val errorData: E) : Either<E, Nothing>()
class Value<V>(val value: V) : Either<Nothing, V>()
}
fun <E> E.error(): Either<E, Nothing> = Either.Error(this)
fun <V> V.value(): Either<Nothing, V> = Either.Value(this)
inline fun <reified E, reified V, reified V2> Either<E, V>.bind(f: (V) -> Either<E, V2>): Either<E, V2> = when(this) {
is Either.Error<E> -> errorData.error()
is Either.Value<V> -> f(value)
}
inline fun <reified E, reified V, reified V2> Either<E, V>.map(f: (V) -> V2): Either<E, V2> = bind { f(it).value() }
inline fun <reified E, reified V, reified R> Either<E, V>.fold(fError: (E) -> R, fValue: (V) -> R) = when(this) {
is Either.Error<E> -> fError(errorData)
is Either.Value<V> -> fValue(value)
}
suspend inline fun <reified E, reified V, reified R> Either<E, V>.foldSuspend(fError: suspend (E) -> R, fValue: suspend (V) -> R) = when(this) {
is Either.Error<E> -> fError(errorData)
is Either.Value<V> -> fValue(value)
}
inline fun <reified E, reified V> Either<E, V>.isError() = this is Either.Error<E>
inline fun <reified E, reified V> Either<E, V>.isValue() = this is Either.Value<V>
inline fun <reified E, reified V> Either<E, V>.asError() = (this as? Either.Error<E>)?.errorData
inline fun <reified E, reified V> Either<E, V>.asValue() = (this as? Either.Value<V>)?.value
@@ -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
}
@@ -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<Float>,
val measurements: List<Float>, 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)
@@ -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<Invite>) {
@Serializable
data class Invite(val code: Uuid, val expires: Instant, val asAdmin: Boolean)
}
@Serializable
data class UserListResponse(val users: List<UserData>) {
@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<ProjectSummary>, val publicProjects: List<ProjectSummary>) {
@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<LabelResponse>, val entries: List<EntryResponse>)
@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<Float>,
val measurements: List<Float>, val unit: TimeUnit)
@Serializable
data class HandshakeResponse(val version: String = ProtocolVersion.VERSION) {
}
@@ -0,0 +1,44 @@
package com.jaytux.phoebench.common
object Routes {
object Auth {
val login = ApiRoute.post<LoginRequest, TokenResponse>("/login", Elevation.UN_AUTH)
val signup = ApiRoute.post<SignupRequest, TokenResponse>("/signup", Elevation.UN_AUTH)
val logout = ApiRoute.post<LogoutRequest, EmptyResponse>("/logout", Elevation.AUTH)
val logoutEverywhere = ApiRoute.post<EmptyRequest, EmptyResponse>("/logout/all", Elevation.AUTH)
val refresh = ApiRoute.post<RefreshRequest, TokenResponse>("/refresh", Elevation.UN_AUTH)
object Invite {
val new = ApiRoute.post<InviteRequest, UuidResponse>("/invite", Elevation.ADMIN)
val list = ApiRoute.get<InviteListResponse>("/invite", Elevation.ADMIN)
val delete = ApiRoute.deleteUuidNoRes("/invite", Elevation.ADMIN)
}
object User {
val list = ApiRoute.get<UserListResponse>("/user", Elevation.ADMIN)
val update = ApiRoute.patchUuidNoRes<UserUpdateRequest>("/user", Elevation.ADMIN)
val delete = ApiRoute.deleteUuidNoRes("/user", Elevation.ADMIN)
}
}
val handshake = ApiRoute.get<HandshakeResponse>("/", Elevation.UN_AUTH)
val home = ApiRoute.get<HomeResponse>("/home", Elevation.AUTH)
object Project {
val new = ApiRoute.post<ProjectRequest, ProjectResponse>("/project", Elevation.AUTH)
val get = ApiRoute.getUuid<ProjectResponse>("/project", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialProjectRequest>("/project", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH)
}
object Label {
val new = ApiRoute.post<LabelRequest, LabelResponse>("/label", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialLabelRequest>("/label", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/label", Elevation.AUTH)
}
object Entry {
val new = ApiRoute.post<EntryRequest, EntryResponse>("/entry", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialEntryRequest>("/entry", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/entry", Elevation.AUTH)
}
}
@@ -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)
}
+2
View File
@@ -0,0 +1,2 @@
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx2048m
+91
View File
@@ -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" }
Binary file not shown.
+6
View File
@@ -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
Vendored Executable
+234
View File
@@ -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" "$@"
Vendored
+89
View File
@@ -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
+13
View File
@@ -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==
+75
View File
@@ -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<Jar> {
manifest {
attributes["Main-Class"] = application.mainClass
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
@@ -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
}
}
@@ -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")
}
}
}
@@ -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<String>) {
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()}"))
}
}
}
@@ -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)
}
@@ -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 <reified T> Table.list(name: String): Column<List<T>> {
val ser = serializer<List<T>>()
val json = Json
return text(name, eagerLoading = true).transform(
wrap = { json.decodeFromString(ser, it) },
unwrap = { json.encodeToString(ser, it) }
)
}
@@ -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...")
}
}
}
}
@@ -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<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, User>(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<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, Invite>(Invites)
var expires by Invites.expires
var inviteAsAdmin by Invites.inviteAsAdmin
}
class RefreshToken(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, RefreshToken>(RefreshTokens)
var userId by RefreshTokens.userId
var expires by RefreshTokens.expires
var user by User referencedOn RefreshTokens.userId
}
class Project(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, Project>(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<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, Label>(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<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, Entry>(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
}
@@ -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<Float>("warmups")
val measurements = list<Float>("measurements")
val unit = enumeration<TimeUnit>("unit")
}
@@ -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<HttpStatusCode, TokenResponse> {
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<Uuid, UserUpdateRequest>) = 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())
}
@@ -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)
}
@@ -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 <reified TReq: Any> ApiRoute<TReq, *>.paramArgs(call: ApplicationCall): TReq =
parseParams(call.parameters) ?: throw RouteError(
"Missing or malformed parameters for ${this.verb} ${this.pattern}",
HttpStatusCode.BadRequest
)
suspend inline fun <reified TReq: Any> ApiRoute<TReq, *>.queryArgs(call: ApplicationCall): TReq =
parseQuery(call.request.queryParameters)
suspend inline fun <reified TReq: Any> ApiRoute<TReq, *>.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 <reified TReq: Any> ApiRoute<TReq, *>.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 <reified TReq: Any> ApiRoute<TReq, *>.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<TReq>()}")
(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 <reified TReq: Any> ApiRoute<TReq, *>.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 <reified TReq: Any, reified TRes: Any> Route.wrapper(
api: ApiRoute<TReq, TRes>, selector: Route.(String, suspend RoutingContext.() -> Unit) -> Route,
crossinline wrapper: suspend (TReq) -> Pair<HttpStatusCode, TRes>
): 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 <reified TReq: Any, reified TRes: Any> Route.wrapperAuth(
api: ApiRoute<TReq, TRes>, selector: Route.(String, suspend RoutingContext.() -> Unit) -> Route,
crossinline wrapper: suspend (User, TReq) -> Pair<HttpStatusCode, TRes>
): 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 <reified TReq: Any, reified TRes: Any> Route.wrapperAdmin(
api: ApiRoute<TReq, TRes>, selector: Route.(String, suspend RoutingContext.() -> Unit) -> Route,
crossinline wrapper: suspend (User, TReq) -> Pair<HttpStatusCode, TRes>
): 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 <reified TReq: Any, reified TRes: Any> Route.get(api: ApiRoute<TReq, TRes>, noinline handler: suspend (TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapper(api, Route::get, handler)
inline fun <reified TReq: Any, reified TRes: Any> Route.getAuth(api: ApiRoute<TReq, TRes>, noinline handler: suspend (User, TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapperAuth(api, Route::get, handler)
inline fun <reified TReq: Any, reified TRes: Any> Route.getAdmin(api: ApiRoute<TReq, TRes>, noinline handler: suspend (User, TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapperAdmin(api, Route::get, handler)
inline fun <reified TReq: Any, reified TRes: Any> Route.post(api: ApiRoute<TReq, TRes>, noinline handler: suspend (TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapper(api, Route::post, handler)
inline fun <reified TReq: Any, reified TRes: Any> Route.postAuth(api: ApiRoute<TReq, TRes>, noinline handler: suspend (User, TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapperAuth(api, Route::post, handler)
inline fun <reified TReq: Any, reified TRes: Any> Route.postAdmin(api: ApiRoute<TReq, TRes>, noinline handler: suspend (User, TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapperAdmin(api, Route::post, handler)
inline fun <reified TReq: Any, reified TRes: Any> Route.delete(api: ApiRoute<TReq, TRes>, noinline handler: suspend (TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapper(api, Route::delete, handler)
inline fun <reified TReq: Any, reified TRes: Any> Route.deleteAuth(api: ApiRoute<TReq, TRes>, noinline handler: suspend (User, TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapperAuth(api, Route::delete, handler)
inline fun <reified TReq: Any, reified TRes: Any> Route.deleteAdmin(api: ApiRoute<TReq, TRes>, noinline handler: suspend (User, TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapperAdmin(api, Route::delete, handler)
inline fun <reified TReq: Any, reified TRes: Any> Route.patch(api: ApiRoute<TReq, TRes>, noinline handler: suspend (TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapper(api, Route::patch, handler)
inline fun <reified TReq: Any, reified TRes: Any> Route.patchAuth(api: ApiRoute<TReq, TRes>, noinline handler: suspend (User, TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapperAuth(api, Route::patch, handler)
inline fun <reified TReq: Any, reified TRes: Any> Route.patchAdmin(api: ApiRoute<TReq, TRes>, noinline handler: suspend (User, TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapperAdmin(api, Route::patch, handler)
@@ -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<Uuid, PartialProjectRequest>) = 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<Uuid, PartialLabelRequest>) = 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<Uuid, PartialEntryRequest>) = 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())
}
}
@@ -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 <reified R: Any> RoutingCall.respondJson(status: HttpStatusCode, body: R) {
respondText(
text = Json.encodeToString(body),
contentType = ContentType.Application.Json,
status = status
)
}
suspend inline fun <reified R: Any> RoutingContext.wrapped(block: suspend RoutingContext.() -> Pair<HttpStatusCode, R>) {
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 <reified R: Any> RoutingContext.wrappedAuth(block: suspend RoutingContext.(user: User) -> Pair<HttpStatusCode, R>) = wrapped {
val principal = call.principal<JWTPrincipal>()
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 <reified R: Any> RoutingContext.wrappedAdmin(block: suspend RoutingContext.(admin: User) -> Pair<HttpStatusCode, R>) = wrappedAuth { user ->
if(!user.isAdmin) {
throw RouteError("Admin access required", HttpStatusCode.Forbidden)
}
block(user)
}
fun <R> success(data: R, status: HttpStatusCode = HttpStatusCode.OK): Pair<HttpStatusCode, R> =
Pair(status, data)
fun unauthorized(message: String): Nothing =
throw RouteError(message, HttpStatusCode.Unauthorized)
}
}
@@ -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
}
@@ -0,0 +1 @@
org.slf4j.simpleLogger.log.io.ktor.server.plugins.cors.CORS=trace
+34
View File
@@ -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")