Initial version (Server, Compose clients work)
This commit is contained in:
+49
@@ -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/
|
||||||
@@ -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"
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+95
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
kotlin.code.style=official
|
||||||
|
kotlin.daemon.jvmargs=-Xmx2048m
|
||||||
@@ -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" }
|
||||||
Vendored
BIN
Binary file not shown.
+6
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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==
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user