CLI client, SSE
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
package com.jaytux.phoebench.server
|
||||
|
||||
import com.jaytux.phoebench.common.AdminEvent
|
||||
import com.jaytux.phoebench.common.HomeEvent
|
||||
import com.jaytux.phoebench.common.ProjectEvent
|
||||
import io.ktor.util.reflect.typeInfo
|
||||
import kotlinx.serialization.serializer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.reflect.typeOf
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object Buses {
|
||||
private val _projectBuses = ConcurrentHashMap<Uuid, SSEBus<ProjectEvent>>()
|
||||
|
||||
val homeBus = SSEBus<HomeEvent>(typeOf<HomeEvent>(), serializer<HomeEvent>())
|
||||
val adminBus = SSEBus<AdminEvent>(typeOf<AdminEvent>(), serializer<AdminEvent>())
|
||||
|
||||
fun projectBus(id: Uuid) = _projectBuses.computeIfAbsent(id) {
|
||||
SSEBus<ProjectEvent>(typeOf<ProjectEvent>(), serializer<ProjectEvent>())
|
||||
}
|
||||
|
||||
fun allBuses(): List<SSEBus<*>> {
|
||||
val res = ArrayList<SSEBus<*>>(_projectBuses.size + 2)
|
||||
res.addAll(_projectBuses.values)
|
||||
res.add(homeBus)
|
||||
res.add(adminBus)
|
||||
return res
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,10 @@
|
||||
package com.jaytux.phoebench.server
|
||||
|
||||
import com.jaytux.phoebench.common.ErrorResponse
|
||||
import com.jaytux.phoebench.common.HomeResponse
|
||||
import com.jaytux.phoebench.common.Routes
|
||||
import com.jaytux.phoebench.common.fold
|
||||
import com.jaytux.phoebench.common.*
|
||||
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.RouteError
|
||||
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 com.jaytux.phoebench.server.handlers.*
|
||||
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
@@ -28,23 +14,15 @@ 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.plugins.cors.routing.*
|
||||
import io.ktor.server.plugins.statuspages.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.sse.SSE
|
||||
import io.ktor.server.sse.sse
|
||||
import kotlinx.serialization.encodeToString
|
||||
import io.ktor.server.sse.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import java.net.URI
|
||||
import kotlin.uuid.Uuid
|
||||
import com.jaytux.phoebench.server.handlers.RouteError.Companion.respondJson
|
||||
import io.ktor.server.sse.heartbeat
|
||||
import io.ktor.sse.ServerSentEvent
|
||||
import io.ktor.utils.io.CancellationException
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
DB.db
|
||||
@@ -156,59 +134,15 @@ fun Application.module() {
|
||||
deleteAuth(Routes.Label.delete, ProjectHandler::deleteLabel)
|
||||
|
||||
postAuth(Routes.Entry.new, ProjectHandler::createEntry)
|
||||
patchAuth(Routes.Entry.update, ProjectHandler::updateEntry)
|
||||
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
|
||||
|
||||
val projectBus = SSEBus.register<HomeResponse.ProjectSummary>("/rt/project")
|
||||
sse("/rt/project") {
|
||||
try {
|
||||
println("Attempt to set up SSE")
|
||||
val principal = call.principal<JWTPrincipal>()
|
||||
val userId = principal?.payload?.getClaim(com.jaytux.phoebench.common.Auth.JWT_CLAIM)?.asString()
|
||||
?: throw RouteError("Missing user claim", HttpStatusCode.Unauthorized)
|
||||
println(" -- SSE: userId = $userId")
|
||||
val user = transaction {
|
||||
User.findById(Uuid.parse(userId)) ?: throw RouteError(
|
||||
"User not found",
|
||||
HttpStatusCode.Unauthorized
|
||||
)
|
||||
}
|
||||
println(" -- SSE: user = ${user.username}")
|
||||
|
||||
heartbeat {}
|
||||
|
||||
val flow = projectBus.register(user.id.value)
|
||||
|
||||
try {
|
||||
flow.collect {
|
||||
it.fold({
|
||||
throw Exception() // force end of collecting
|
||||
}) { event ->
|
||||
send(ServerSentEvent(data = Json.encodeToString(projectBus.serializer, event)))
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(e: CancellationException) {
|
||||
projectBus.disconnect(user.id.value)
|
||||
throw e
|
||||
}
|
||||
sseAuth(Routes.SSE.home, { _, _ -> }) { _, _ -> Buses.homeBus }
|
||||
sseAdmin(Routes.SSE.admin) { _, _ -> Buses.adminBus }
|
||||
sseAuth(Routes.SSE.projectSpecific,
|
||||
{ user, uuid ->
|
||||
transaction { accessibleProject(user, uuid, false) }
|
||||
}
|
||||
catch(e: RouteError) {
|
||||
call.respondText(
|
||||
status = e.status,
|
||||
text = Json.encodeToString(ErrorResponse(e.message ?: "Unknown error")),
|
||||
contentType = ContentType.Application.Json
|
||||
)
|
||||
}
|
||||
|
||||
println("--- SSE Session ended ---")
|
||||
}
|
||||
|
||||
sse("/rt/users") {}
|
||||
|
||||
sse("/rt/invites") {}
|
||||
|
||||
sse("/rt/project/{project-id}") {}
|
||||
) { _, id -> Buses.projectBus(id) }
|
||||
}
|
||||
|
||||
get("{...}") {
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.jaytux.phoebench.common.Either
|
||||
import com.jaytux.phoebench.common.error
|
||||
import com.jaytux.phoebench.common.value
|
||||
import io.ktor.util.reflect.instanceOf
|
||||
import io.ktor.websocket.Serializer
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.serialization.KSerializer
|
||||
@@ -13,14 +14,17 @@ import kotlin.reflect.KType
|
||||
import kotlin.reflect.typeOf
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class SSEBus<T> private constructor (private val _containedType: KType, val serializer: KSerializer<T>) {
|
||||
class SSEBus<T>(private val _containedType: KType, val serializer: KSerializer<T>) {
|
||||
object Cancellation
|
||||
|
||||
private val _unAuthFlow = MutableSharedFlow<T>()
|
||||
private val _flows = ConcurrentHashMap<Uuid, Pair<Int, MutableSharedFlow<Either<Cancellation, T>>>>()
|
||||
|
||||
fun register(user: Uuid): SharedFlow<Either<Cancellation, T>> =
|
||||
_flows.computeIfAbsent(user) { 1 to MutableSharedFlow(extraBufferCapacity = 64) }.second
|
||||
|
||||
fun unRegistered(): SharedFlow<T> = _unAuthFlow
|
||||
|
||||
suspend fun send(user: Uuid, data: T) {
|
||||
_flows[user]?.second?.emit(data.value())
|
||||
}
|
||||
@@ -29,6 +33,10 @@ class SSEBus<T> private constructor (private val _containedType: KType, val seri
|
||||
_flows.forEach { it.value.second.emit(data.value()) }
|
||||
}
|
||||
|
||||
suspend fun sendUnAuth(data: T) {
|
||||
_unAuthFlow.emit(data)
|
||||
}
|
||||
|
||||
fun disconnect(user: Uuid) {
|
||||
_flows.compute(user) { _, data ->
|
||||
if(data == null) null
|
||||
@@ -46,30 +54,11 @@ class SSEBus<T> private constructor (private val _containedType: KType, val seri
|
||||
_flows.remove(user)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val _busCache = mutableMapOf<String, SSEBus<*>>()
|
||||
|
||||
fun <T> register(topic: String, contained: KType, serializer: KSerializer<T>): SSEBus<T> {
|
||||
val bus = SSEBus<T>(contained, serializer)
|
||||
_busCache.compute(topic) { k, existing ->
|
||||
if(existing != null) throw IllegalArgumentException("Bus for $topic exists already")
|
||||
bus
|
||||
}
|
||||
return bus
|
||||
}
|
||||
|
||||
inline fun <reified T> register(topic: String) =
|
||||
register<T>(topic, typeOf<T>(), serializer<T>())
|
||||
|
||||
fun <T> getBus(topic: String, contained: KType): SSEBus<T>? {
|
||||
val bus = _busCache[topic] ?: return null
|
||||
if(contained != bus._containedType) throw IllegalArgumentException("Type mismatch for bus for $topic")
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return bus as SSEBus<T>
|
||||
}
|
||||
|
||||
inline fun <reified T> getBus(topic: String) =
|
||||
getBus<T>(topic, typeOf<T>())
|
||||
suspend fun forceDisconnectExcept(user: Uuid) {
|
||||
synchronized(_flows) {
|
||||
val map = _flows.values.filter { it != _flows[user] }
|
||||
_flows.keys.retainAll(setOf(user))
|
||||
map
|
||||
}.forEach { it.second.emit(Cancellation.error()) }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.jaytux.phoebench.server.handlers
|
||||
|
||||
import com.jaytux.phoebench.common.*
|
||||
import com.jaytux.phoebench.server.Auth
|
||||
import com.jaytux.phoebench.server.Buses
|
||||
import com.jaytux.phoebench.server.SSEBus
|
||||
import com.jaytux.phoebench.server.db.Invite
|
||||
import com.jaytux.phoebench.server.db.RefreshToken
|
||||
import com.jaytux.phoebench.server.db.RefreshTokens
|
||||
@@ -14,6 +16,7 @@ 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.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import org.jetbrains.exposed.v1.core.Transaction
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
@@ -71,6 +74,13 @@ object AuthHandler {
|
||||
_hasOwner = true
|
||||
}
|
||||
logger.info("New user: ${user.username} (${user.id.value}; is admin? ${user.isAdmin})")
|
||||
ServerScope.launch {
|
||||
Buses.adminBus.sendAll(
|
||||
AdminEvent.NewUser(
|
||||
UserListResponse.UserData(user.id.value, user.username, user.isAdmin, user.projectLimit, 0)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val access = Auth.generate(user.id.value)
|
||||
val refresh = newRefreshToken(user)
|
||||
@@ -150,6 +160,9 @@ object AuthHandler {
|
||||
inviteAsAdmin = if(user.isOwner) req.asAdmin else false
|
||||
expires = nowPlus(48, DateTimeUnit.HOUR)
|
||||
}
|
||||
ServerScope.launch {
|
||||
Buses.adminBus.sendAll(AdminEvent.NewInvite(InviteListResponse.Invite(invite.id.value, invite.expires, invite.inviteAsAdmin)))
|
||||
}
|
||||
success(UuidResponse(invite.id.value))
|
||||
}
|
||||
|
||||
@@ -162,6 +175,9 @@ object AuthHandler {
|
||||
suspend fun deleteInvite(user: User, req: Uuid) = transaction {
|
||||
val inv = Invite.findById(req) ?: throw RouteError("Invalid invite code", HttpStatusCode.NotFound)
|
||||
inv.delete()
|
||||
ServerScope.launch {
|
||||
Buses.adminBus.sendAll(AdminEvent.InviteDeleted(req))
|
||||
}
|
||||
success(EmptyResponse())
|
||||
}
|
||||
|
||||
@@ -184,6 +200,16 @@ object AuthHandler {
|
||||
if(user.isOwner) target.isAdmin = it
|
||||
else throw RouteError("Only the server owner can change admin status.", HttpStatusCode.Forbidden)
|
||||
}
|
||||
|
||||
ServerScope.launch {
|
||||
Buses.adminBus.sendAll(
|
||||
AdminEvent.UserChanged(
|
||||
UserListResponse.UserData(user.id.value, user.username, user.isAdmin, user.projectLimit, 0)
|
||||
)
|
||||
)
|
||||
if(changes.isAdmin == false) Buses.adminBus.forceDisconnect(req.first)
|
||||
}
|
||||
|
||||
success(EmptyResponse())
|
||||
}
|
||||
|
||||
@@ -195,6 +221,10 @@ object AuthHandler {
|
||||
target.delete()
|
||||
}
|
||||
else throw RouteError("Only the owner can delete admin accounts.", HttpStatusCode.Forbidden)
|
||||
ServerScope.launch {
|
||||
Buses.adminBus.sendAll(AdminEvent.UserDeleted(req))
|
||||
Buses.allBuses().forEach { it.forceDisconnect(req) }
|
||||
}
|
||||
success(EmptyResponse())
|
||||
}
|
||||
|
||||
|
||||
@@ -4,20 +4,41 @@ 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.Either
|
||||
import com.jaytux.phoebench.common.Elevation
|
||||
import com.jaytux.phoebench.common.EmptyRequest
|
||||
import com.jaytux.phoebench.common.ErrorResponse
|
||||
import com.jaytux.phoebench.common.SSERoute
|
||||
import com.jaytux.phoebench.common.foldSuspend
|
||||
import com.jaytux.phoebench.server.Auth.setup
|
||||
import com.jaytux.phoebench.server.SSEBus
|
||||
import com.jaytux.phoebench.server.db.User
|
||||
import com.jaytux.phoebench.server.handlers.RouteError
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||
import io.ktor.server.auth.principal
|
||||
import io.ktor.server.plugins.BadRequestException
|
||||
import io.ktor.server.plugins.ContentTransformationException
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respondText
|
||||
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.server.sse.heartbeat
|
||||
import io.ktor.server.sse.sse
|
||||
import io.ktor.sse.ServerSentEvent
|
||||
import io.ktor.util.reflect.typeInfo
|
||||
import io.ktor.utils.io.CancellationException
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
suspend inline fun <reified TReq: Any> ApiRoute<TReq, *>.paramArgs(call: ApplicationCall): TReq =
|
||||
parseParams(call.parameters) ?: throw RouteError(
|
||||
@@ -140,4 +161,101 @@ inline fun <reified TReq: Any, reified TRes: Any> Route.patchAuth(api: ApiRoute<
|
||||
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)
|
||||
wrapperAdmin(api, Route::patch, handler)
|
||||
|
||||
inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrapSSE(
|
||||
api: SSERoute<TParams, TEvent>, noinline extra: suspend (ApplicationCall, TParams) -> TInter,
|
||||
noinline prepare: suspend (TInter, TParams) -> SSEBus<TEvent>,
|
||||
noinline extract: suspend (SSEBus<TEvent>, TInter, TParams) -> SharedFlow<TFlow>,
|
||||
noinline handler: suspend (TFlow, sender: suspend (TEvent) -> Unit) -> Unit,
|
||||
noinline onCancel: suspend (SSEBus<TEvent>, TInter, TParams, CancellationException) -> Unit
|
||||
) {
|
||||
sse(api.pattern) {
|
||||
try {
|
||||
heartbeat {
|
||||
period = 1.seconds
|
||||
}
|
||||
|
||||
val params = api.extractParams(call.parameters) ?: throw RouteError(
|
||||
"Missing or malformed parameters for SSE ${api.pattern}",
|
||||
HttpStatusCode.BadRequest
|
||||
)
|
||||
|
||||
val inter = extra(call, params)
|
||||
val bus = prepare(inter, params)
|
||||
val stream = extract(bus, inter, params)
|
||||
try {
|
||||
stream.collect {
|
||||
handler(it) { ev -> send(ServerSentEvent(data = Json.encodeToString(bus.serializer, ev))) }
|
||||
}
|
||||
}
|
||||
catch(e: CancellationException) {
|
||||
onCancel(bus, inter, params, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
catch(e: BadRequestException) {
|
||||
call.respondText(
|
||||
status = HttpStatusCode.BadRequest,
|
||||
text = Json.encodeToString(ErrorResponse(e.message ?: "Unknown error")),
|
||||
contentType = ContentType.Application.Json
|
||||
)
|
||||
}
|
||||
catch(e: RouteError) {
|
||||
call.respondText(
|
||||
status = e.status,
|
||||
text = Json.encodeToString(ErrorResponse(e.message ?: "Unknown error")),
|
||||
contentType = ContentType.Application.Json
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
|
||||
api: SSERoute<TParams, TEvent>,
|
||||
noinline verifyUser: suspend (User, TParams) -> Unit,
|
||||
noinline prepare: suspend (User, TParams) -> SSEBus<TEvent>
|
||||
) = wrapSSE(api,
|
||||
extra = { call, params ->
|
||||
val principal = call.principal<JWTPrincipal>()
|
||||
val userId = principal?.payload?.getClaim(com.jaytux.phoebench.common.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
|
||||
)
|
||||
}
|
||||
verifyUser(user, params)
|
||||
user
|
||||
},
|
||||
prepare = prepare,
|
||||
extract = { bus, user, _ -> bus.register(user.id.value) },
|
||||
handler = { event, sender ->
|
||||
event.foldSuspend({
|
||||
throw RouteError("This event stream has been discontinued.", HttpStatusCode.Locked)
|
||||
}) { sender(it) }
|
||||
},
|
||||
onCancel = { bus, user, _, _ -> bus.disconnect(user.id.value) }
|
||||
)
|
||||
|
||||
inline fun <reified TParams: Any, reified TEvent: Any> Route.sse(api: SSERoute<TParams, TEvent>, noinline setup: suspend (TParams) -> SSEBus<TEvent>) =
|
||||
wrapSSE(api,
|
||||
extra = { _, _ -> },
|
||||
prepare = { _, params -> setup(params) },
|
||||
extract = { bus, _, _ -> bus.unRegistered() },
|
||||
handler = { it, sender -> sender(it) },
|
||||
onCancel = { _, _, _, _ -> }
|
||||
)
|
||||
|
||||
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAuth(api: SSERoute<TParams, TEvent>,
|
||||
noinline verifyUser: suspend (User, TParams) -> Unit, noinline setup: suspend (User, TParams) -> SSEBus<TEvent>
|
||||
) = wrapAuthSSE(api, verifyUser, setup)
|
||||
|
||||
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAdmin(api: SSERoute<TParams, TEvent>,
|
||||
noinline setup: suspend (User, TParams) -> SSEBus<TEvent>
|
||||
) = wrapAuthSSE(api, { user, _ ->
|
||||
if(!user.isAdmin) {
|
||||
throw RouteError("Admin access required", HttpStatusCode.Forbidden)
|
||||
}
|
||||
}, setup)
|
||||
@@ -4,6 +4,7 @@ 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.HomeEvent
|
||||
import com.jaytux.phoebench.common.HomeResponse
|
||||
import com.jaytux.phoebench.common.LabelRequest
|
||||
import com.jaytux.phoebench.common.LabelResponse
|
||||
@@ -11,8 +12,10 @@ 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.ProjectEvent
|
||||
import com.jaytux.phoebench.common.ProjectRequest
|
||||
import com.jaytux.phoebench.common.ProjectResponse
|
||||
import com.jaytux.phoebench.server.Buses
|
||||
import com.jaytux.phoebench.server.SSEBus
|
||||
import com.jaytux.phoebench.server.db.Entries
|
||||
import com.jaytux.phoebench.server.db.Entry
|
||||
@@ -23,6 +26,8 @@ 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 kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.Transaction
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
@@ -31,10 +36,10 @@ import kotlin.uuid.Uuid
|
||||
|
||||
object ProjectHandler {
|
||||
context(trns: Transaction)
|
||||
private fun Project.isEditableBy(user: User): Boolean = ownerId.value == user.id.value
|
||||
fun Project.isEditableBy(user: User): Boolean = ownerId.value == user.id.value
|
||||
|
||||
context(trns: Transaction)
|
||||
private fun Project.isAccessible(user: User, forEditing: Boolean): Project {
|
||||
fun Project.isAccessible(user: User, forEditing: Boolean): Project {
|
||||
return when {
|
||||
isEditableBy(user) -> this
|
||||
isPublic && !forEditing -> this
|
||||
@@ -42,7 +47,7 @@ object ProjectHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private fun Transaction.accessibleProject(user: User, id: Uuid, forEditing: Boolean): Project {
|
||||
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)
|
||||
}
|
||||
@@ -77,10 +82,12 @@ object ProjectHandler {
|
||||
|
||||
success(proj.toResponse(user))
|
||||
}.also { (_, proj) ->
|
||||
val bus = SSEBus.getBus<HomeResponse.ProjectSummary>("/rt/project") ?: return@also
|
||||
val summary = HomeResponse.ProjectSummary(proj.id, proj.name, proj.isPublic, NamedID(user.username, user.id.value))
|
||||
if(proj.isPublic) bus.sendAll(summary)
|
||||
else bus.send(user.id.value, summary)
|
||||
ServerScope.launch {
|
||||
val bus = Buses.homeBus
|
||||
val summary = HomeResponse.ProjectSummary(proj.id, proj.name, proj.isPublic, NamedID(user.username, user.id.value))
|
||||
if(proj.isPublic) bus.sendAll(HomeEvent.Created(summary))
|
||||
else bus.send(user.id.value, HomeEvent.Created(summary))
|
||||
}
|
||||
}
|
||||
|
||||
fun getProject(user: User, req: Uuid) = transaction {
|
||||
@@ -93,11 +100,30 @@ object ProjectHandler {
|
||||
val changes = req.second
|
||||
changes.name?.let { proj.name = it }
|
||||
changes.isPublic?.let { proj.isPublic = it }
|
||||
|
||||
ServerScope.launch {
|
||||
val bus = Buses.homeBus
|
||||
val summary = HomeResponse.ProjectSummary(proj.id.value, proj.name, proj.isPublic, NamedID(user.username, user.id.value))
|
||||
if(proj.isPublic) bus.sendAll(HomeEvent.Changed(summary))
|
||||
else bus.send(user.id.value, HomeEvent.Changed(summary))
|
||||
|
||||
val projectBus = Buses.projectBus(req.first)
|
||||
projectBus.sendAll(ProjectEvent.Updated(summary))
|
||||
if(changes.isPublic == false) projectBus.forceDisconnectExcept(proj.owner.id.value)
|
||||
}
|
||||
|
||||
success(EmptyResponse())
|
||||
}
|
||||
|
||||
fun deleteProject(user: User, req: Uuid) = transaction {
|
||||
accessibleProject(user, req, true).delete()
|
||||
val proj = accessibleProject(user, req, true)
|
||||
proj.delete()
|
||||
ServerScope.launch {
|
||||
val bus = Buses.homeBus
|
||||
if(proj.isPublic) bus.sendAll(HomeEvent.Deleted(req))
|
||||
else bus.send(user.id.value, HomeEvent.Deleted(req))
|
||||
Buses.projectBus(req).sendAll(ProjectEvent.Deleted)
|
||||
}
|
||||
success(EmptyResponse())
|
||||
}
|
||||
|
||||
@@ -109,7 +135,13 @@ object ProjectHandler {
|
||||
color = req.color
|
||||
project = proj
|
||||
}
|
||||
success(LabelResponse(lbl.id.value, lbl.label, lbl.color))
|
||||
|
||||
val res = LabelResponse(lbl.id.value, lbl.label, lbl.color)
|
||||
ServerScope.launch {
|
||||
Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewLabel(res))
|
||||
}
|
||||
|
||||
success(res)
|
||||
}
|
||||
|
||||
fun updateLabel(user: User, req: Pair<Uuid, PartialLabelRequest>) = transaction {
|
||||
@@ -121,6 +153,13 @@ object ProjectHandler {
|
||||
if(it.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest)
|
||||
lbl.color = it
|
||||
}
|
||||
|
||||
ServerScope.launch {
|
||||
Buses.projectBus(lbl.projectId.value).sendAll(ProjectEvent.LabelChanged(
|
||||
LabelResponse(lbl.id.value, lbl.label, lbl.color)
|
||||
))
|
||||
}
|
||||
|
||||
success(EmptyResponse())
|
||||
}
|
||||
|
||||
@@ -128,6 +167,11 @@ object ProjectHandler {
|
||||
val lbl = Label.findById(req) ?: throw RouteError("Invalid label ID.", HttpStatusCode.NotFound)
|
||||
lbl.project.isAccessible(user, true)
|
||||
lbl.delete()
|
||||
|
||||
ServerScope.launch {
|
||||
Buses.projectBus(lbl.id.value).sendAll(ProjectEvent.LabelDeleted(req))
|
||||
}
|
||||
|
||||
success(EmptyResponse())
|
||||
}
|
||||
|
||||
@@ -145,32 +189,21 @@ object ProjectHandler {
|
||||
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
|
||||
}
|
||||
val response = EntryResponse(entry.id.value, entry.label.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit)
|
||||
ServerScope.launch {
|
||||
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response))
|
||||
}
|
||||
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())
|
||||
success(response)
|
||||
}
|
||||
|
||||
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()
|
||||
ServerScope.launch {
|
||||
Buses.projectBus(entry.project.id.value).sendAll(ProjectEvent.EntryDeleted(req))
|
||||
}
|
||||
success(EmptyResponse())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.jaytux.phoebench.server.handlers
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
object ServerScope : CoroutineScope {
|
||||
private val _actualScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
override val coroutineContext: CoroutineContext = _actualScope.coroutineContext
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
org.slf4j.simpleLogger.log.io.ktor.server.plugins.cors.CORS=trace
|
||||
Reference in New Issue
Block a user