Initial version (Server, Compose clients work)

This commit is contained in:
2026-08-04 12:12:25 +02:00
commit 2a1487037b
59 changed files with 4701 additions and 0 deletions
@@ -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