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