Initial version (Server, Compose clients work)
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user