diff --git a/WebsiteBackend/src/de/steamwar/Application.kt b/WebsiteBackend/src/de/steamwar/Application.kt index e49b9a9d..9853d4ab 100644 --- a/WebsiteBackend/src/de/steamwar/Application.kt +++ b/WebsiteBackend/src/de/steamwar/Application.kt @@ -20,12 +20,9 @@ package de.steamwar import de.steamwar.plugins.configurePlugins -import de.steamwar.routes.ResponseUser -import de.steamwar.routes.SchematicCode import io.ktor.server.application.* import io.ktor.server.engine.* import de.steamwar.routes.configureRoutes -import de.steamwar.sql.SchematicType import de.steamwar.sql.SteamwarUser import io.ktor.server.netty.* import kotlinx.serialization.ExperimentalSerializationApi @@ -47,7 +44,6 @@ fun main() { Thread { while (true) { Thread.sleep(1000 * 10) - ResponseUser.clearCache() SteamwarUser.clear() } }.start() diff --git a/WebsiteBackend/src/de/steamwar/routes/AuditLog.kt b/WebsiteBackend/src/de/steamwar/routes/AuditLog.kt new file mode 100644 index 00000000..7ac08d5f --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/AuditLog.kt @@ -0,0 +1,215 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2025 SteamWar.de-Serverteam + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package de.steamwar.routes + +import de.steamwar.plugins.SWPermissionCheck +import de.steamwar.sql.AuditLog +import de.steamwar.sql.AuditLogTable +import de.steamwar.sql.SteamwarUserTable +import de.steamwar.sql.UserPerm +import de.steamwar.sql.internal.useDb +import io.ktor.server.application.call +import io.ktor.server.application.install +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.route +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.v1.core.Alias +import org.jetbrains.exposed.v1.core.JoinType +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.alias +import org.jetbrains.exposed.v1.core.greater +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.core.less +import org.jetbrains.exposed.v1.core.like +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.jdbc.Query +import org.jetbrains.exposed.v1.jdbc.andWhere +import org.jetbrains.exposed.v1.jdbc.select +import org.jetbrains.exposed.v1.jdbc.selectAll +import java.time.Instant + +fun Route.configureAuditLog() { + route("/auditlog") { + install(SWPermissionCheck) { + mustAuth = true + permission = UserPerm.MODERATION + } + + get { + val text = call.request.queryParameters["fullText"] + val actionText = call.request.queryParameters["actionText"] + val serverText = call.request.queryParameters["server"] + val actor = call.request.queryParameters.getAll("actor")?.map { it.toInt() }?.toSet() + val actionType = + call.request.queryParameters.getAll("actionType")?.map { AuditLog.Type.valueOf(it) }?.toSet() + val timeGreater = call.request.queryParameters["timeGreater"]?.let { Instant.ofEpochMilli(it.toLong()) } + val timeLess = call.request.queryParameters["timeLess"]?.let { Instant.ofEpochMilli(it.toLong()) } + + val serverOwner = call.request.queryParameters.getAll("serverOwner")?.map { it.toInt() }?.toSet() + val velocity = call.request.queryParameters["velocity"]?.toBoolean() + + val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 0 + val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100 + val sorting = call.request.queryParameters["sorting"] ?: "DESC" + + call.respond( + PagedAuditLog.filter( + actionText, + serverText, + text, + actor, + actionType, + timeGreater, + timeLess, + serverOwner, + velocity, + page, + limit, + sorting + ) + ) + } + } +} + +@Serializable +data class PagedAuditLog(val rows: Long, val entries: List) { + + companion object { + fun filter( + actionText: String? = null, + serverText: String? = null, + fullText: String? = null, + actor: Set? = null, + actionType: Set? = null, + timeGreater: Instant? = null, + timeLess: Instant? = null, + serverOwner: Set? = null, + velocity: Boolean? = null, + page: Int = 0, + limit: Int = 100, + sorting: String = "DESC" + ) = useDb { + val actorTable = SteamwarUserTable.alias("actor") + val serverOwnerTable = SteamwarUserTable.alias("serverOwner") + + val query = AuditLogTable.join( + actorTable, + JoinType.INNER, + onColumn = actorTable[SteamwarUserTable.id], + otherColumn = AuditLogTable.actor + ) + .join( + serverOwnerTable, + JoinType.LEFT, + onColumn = serverOwnerTable[SteamwarUserTable.id], + otherColumn = AuditLogTable.serverOwner + ) + .select( + actorTable[SteamwarUserTable.username], serverOwnerTable[SteamwarUserTable.username], + *AuditLogTable.columns.toTypedArray() + ) + + PagedAuditLog( + AuditLogTable.selectAll().addAuditLogFilters( + actionText, + serverText, + fullText, + actor, + actionType, + timeGreater, + timeLess, + serverOwner, + velocity + ).count(), + query + .addAuditLogFilters( + actionText, + serverText, + fullText, + actor, + actionType, + timeGreater, + timeLess, + serverOwner, + velocity + ) + .limit(limit) + .offset((page * limit).toLong()) + .orderBy(AuditLogTable.time, SortOrder.valueOf(sorting.uppercase())) + .map { + AuditLogEntry( + it[AuditLogTable.id].value, + it[AuditLogTable.time].toEpochMilli(), + it[AuditLogTable.server], + it[serverOwnerTable[SteamwarUserTable.username]], + it[actorTable[SteamwarUserTable.username]], + it[AuditLogTable.action], + it[AuditLogTable.actionText] + ) + }, + ) + } + + private fun Query.addAuditLogFilters( + actionText: String? = null, + serverText: String? = null, + fullText: String? = null, + actor: Set? = null, + actionType: Set? = null, + timeGreater: Instant? = null, + timeLess: Instant? = null, + serverOwner: Set? = null, + velocity: Boolean? = null, + ): Query { + actionText?.let { + andWhere { (AuditLogTable.actionText like "%$it%") } + } + serverText?.let { + andWhere { (AuditLogTable.server like "%$it%") } + } + fullText?.let { + andWhere { (AuditLogTable.actionText like "%$it%") or (AuditLogTable.server like "%$it%") } + } + actor?.let { andWhere { AuditLogTable.actor inList actor } } + actionType?.let { andWhere { AuditLogTable.action inList actionType } } + timeGreater?.let { andWhere { AuditLogTable.time greater timeGreater } } + timeLess?.let { andWhere { AuditLogTable.time less timeLess } } + serverOwner?.let { andWhere { AuditLogTable.serverOwner inList serverOwner } } + if (velocity == true) andWhere { AuditLogTable.serverOwner.isNull() } + + return this + } + } +} + +@Serializable +data class AuditLogEntry( + val id: Int, + val time: Long, + val server: String, + val serverOwner: String?, + val actor: String, + val actionType: AuditLog.Type, + val actionText: String +) \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/Auth.kt b/WebsiteBackend/src/de/steamwar/routes/Auth.kt index 19f300f0..4710bd5b 100644 --- a/WebsiteBackend/src/de/steamwar/routes/Auth.kt +++ b/WebsiteBackend/src/de/steamwar/routes/Auth.kt @@ -66,7 +66,7 @@ fun Route.configureAuth() { } call.sessions.set(SWUserSession(user.getId())) - call.respond(ResponseUser.get(user)) + call.respond(ResponseUser(user)) } delete { @@ -100,7 +100,7 @@ fun Route.configureAuth() { } call.sessions.set(SWUserSession(user.getId())) - call.respond(ResponseUser.get(user)) + call.respond(ResponseUser(user)) } } } diff --git a/WebsiteBackend/src/de/steamwar/routes/Data.kt b/WebsiteBackend/src/de/steamwar/routes/Data.kt index 9cc7be0c..e52f72aa 100644 --- a/WebsiteBackend/src/de/steamwar/routes/Data.kt +++ b/WebsiteBackend/src/de/steamwar/routes/Data.kt @@ -25,6 +25,7 @@ import de.steamwar.plugins.SWAuthPrincipal import de.steamwar.plugins.SWPermissionCheck import de.steamwar.sql.SchematicType import de.steamwar.sql.SteamwarUser +import de.steamwar.sql.SteamwarUserTable import de.steamwar.sql.Team import de.steamwar.sql.UserPerm import de.steamwar.sql.internal.useDb @@ -36,6 +37,13 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import kotlinx.serialization.Serializable import org.bspfsystems.yamlconfiguration.file.YamlConfiguration +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.like +import org.jetbrains.exposed.v1.jdbc.Query +import org.jetbrains.exposed.v1.jdbc.andWhere +import org.jetbrains.exposed.v1.jdbc.selectAll import java.io.File import java.net.InetSocketAddress import java.util.* @@ -44,28 +52,39 @@ import java.util.* data class ResponseSchematicType(val name: String, val db: String) @Serializable -data class ResponseUser(val name: String, val uuid: String, val prefix: String, val perms: List) { - private constructor(user: SteamwarUser) : this(user.userName, user.uuid.toString(), user.prefix().chatPrefix, user.perms().filter { !it.name.startsWith("PREFIX_") }.map { it.name }) +data class ResponseUser( + val name: String, val uuid: String, val id: Int?, val perms: List?, val prefix: String? +) { + constructor(user: SteamwarUser, includeId: Boolean = false, includePerms: Boolean = false) : this( + user.userName, + user.uuid.toString(), + if (includeId) user.getId() else null, + if (includePerms) user.perms().map { it.name } else null, + if (includePerms) user.prefix().chatPrefix else null, + ) - companion object { - private val cache = mutableMapOf() + constructor(row: ResultRow, includeId: Boolean = false, includePerms: Boolean = false, prefixes: MutableSet = mutableSetOf()) : this( + row[SteamwarUserTable.username], + row[SteamwarUserTable.uuid], + if (includeId) row[SteamwarUserTable.id].value else null, + if (includePerms) UserPerm.getPerms(row[SteamwarUserTable.id].value).also { prefixes.addAll(it) }.map { it.name } else null, + if (includePerms) prefixes.firstOrNull { UserPerm.prefixes.containsKey(it) }?.let { UserPerm.prefixes[it]!!.chatPrefix } else null + ) +} - fun get(id: Int): ResponseUser { - synchronized(cache) { - return cache[id] ?: ResponseUser(SteamwarUser.byId(id)!!).also { cache[id] = it } - } - } +@Serializable +data class ResponseUserList(val entries: List, val rows: Long) - fun get(user: SteamwarUser): ResponseUser = synchronized(cache) { - return cache[user.getId()] ?: ResponseUser(user).also { cache[user.getId()] = it } - } +private fun Query.addUserFilter( + name: String? = null, + uuid: UUID? = null, + team: Set? = null, +): Query { + name?.let { andWhere { (SteamwarUserTable.username like "%$it%") } } + uuid?.let { andWhere { (SteamwarUserTable.uuid eq it.toString()) } } + team?.let { andWhere { (SteamwarUserTable.team inList team) } } - fun clearCache() { - synchronized(cache) { - cache.clear() - } - } - } + return this } fun Route.configureDataRoutes() { @@ -76,13 +95,33 @@ fun Route.configureDataRoutes() { permission = UserPerm.MODERATION } get("/users") { - call.respond(useDb { SteamwarUser.all().map { ResponseUser.get(it) } }) + val name = call.request.queryParameters["name"] + val uuid = call.request.queryParameters["uuid"]?.let { catchException { UUID.fromString(it) } } + val team = call.request.queryParameters.getAll("team")?.map { it.toInt() }?.toSet() + + val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100 + val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 0 + + val includePerms = call.request.queryParameters["includePerms"]?.toBoolean() ?: false + val includeId = call.request.queryParameters["includeId"]?.toBoolean() ?: false + + call.respond( + useDb { + ResponseUserList( + SteamwarUserTable.selectAll().addUserFilter(name, uuid, team).limit(limit) + .offset((page * limit).toLong()) + .map { ResponseUser(it, includeId, includePerms) }, + SteamwarUserTable.selectAll().addUserFilter(name, uuid, team).count() + ) + } + ) } get("/teams") { call.respond(Team.getAll().map { ResponseTeam(it) }) } get("/schematicTypes") { - call.respond(SchematicType.values().filter { !it.check() }.map { ResponseSchematicType(it.name(), it.toDB()) }) + call.respond(SchematicType.values().filter { !it.check() } + .map { ResponseSchematicType(it.name(), it.toDB()) }) } get("/gamemodes") { call.respond( @@ -116,11 +155,14 @@ fun Route.configureDataRoutes() { } get("/team") { call.respond( - listOf(UserPerm.PREFIX_ADMIN, UserPerm.PREFIX_DEVELOPER, UserPerm.PREFIX_MODERATOR, UserPerm.PREFIX_SUPPORTER, UserPerm.PREFIX_BUILDER) - .associateWith { SteamwarUser.getUsersWithPerm(it) } - .mapKeys { UserPerm.prefixes[it.key]!!.chatPrefix } - .mapValues { it.value.map { ResponseUser.get(it) } } - ) + listOf( + UserPerm.PREFIX_ADMIN, + UserPerm.PREFIX_DEVELOPER, + UserPerm.PREFIX_MODERATOR, + UserPerm.PREFIX_SUPPORTER, + UserPerm.PREFIX_BUILDER + ).associateWith { SteamwarUser.getUsersWithPerm(it) }.mapKeys { UserPerm.prefixes[it.key]!!.chatPrefix } + .mapValues { it.value.map { ResponseUser(it) } }) } get("/skin/{uuid}") { val uuid = call.parameters["uuid"] @@ -138,7 +180,7 @@ fun Route.configureDataRoutes() { route("/me") { install(SWPermissionCheck) get { - call.respond(ResponseUser.get(call.principal()!!.user)) + call.respond(ResponseUser(call.principal()!!.user, includePerms = true)) } } } diff --git a/WebsiteBackend/src/de/steamwar/routes/EventReferees.kt b/WebsiteBackend/src/de/steamwar/routes/EventReferees.kt index 4997b1fa..0c8bbf3b 100644 --- a/WebsiteBackend/src/de/steamwar/routes/EventReferees.kt +++ b/WebsiteBackend/src/de/steamwar/routes/EventReferees.kt @@ -31,7 +31,7 @@ fun Route.configureEventRefereesRouting() { route("/referees") { get { val event = call.receiveEvent() ?: return@get - call.respond(Referee.get(event.eventID).map { ResponseUser.get(SteamwarUser.byId(it)!!) }) + call.respond(Referee.get(event.eventID).map { ResponseUser(SteamwarUser.byId(it)!!) }) } put { val event = call.receiveEvent() ?: return@put @@ -39,7 +39,7 @@ fun Route.configureEventRefereesRouting() { referees.forEach { Referee.add(event.eventID, SteamwarUser.get(UUID.fromString(it))!!.getId()) } - call.respond(Referee.get(event.eventID).map { ResponseUser.get(SteamwarUser.byId(it)!!) }) + call.respond(Referee.get(event.eventID).map { ResponseUser(SteamwarUser.byId(it)!!) }) } delete { val event = call.receiveEvent() ?: return@delete @@ -47,7 +47,7 @@ fun Route.configureEventRefereesRouting() { referees.forEach { Referee.remove(event.eventID, SteamwarUser.get(UUID.fromString(it))!!.getId()) } - call.respond(Referee.get(event.eventID).map { ResponseUser.get(SteamwarUser.byId(it)!!) }) + call.respond(Referee.get(event.eventID).map { ResponseUser(SteamwarUser.byId(it)!!) }) } } } \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/Events.kt b/WebsiteBackend/src/de/steamwar/routes/Events.kt index e82fc495..2c76498b 100644 --- a/WebsiteBackend/src/de/steamwar/routes/Events.kt +++ b/WebsiteBackend/src/de/steamwar/routes/Events.kt @@ -118,7 +118,7 @@ data class ExtendedResponseEvent( TeamTeilnahme.getTeams(event.eventID).map { ResponseTeam(it) }, EventGroup.get(event).map { ResponseGroups(it) }, EventFight.getEvent(event.eventID).map { ResponseEventFight(it) }, - Referee.get(event.eventID).map { ResponseUser.get(SteamwarUser.byId(it)!!) }, + Referee.get(event.eventID).map { ResponseUser(SteamwarUser.byId(it)!!) }, EventRelation.get(event).map { ResponseRelation(it) } ) } diff --git a/WebsiteBackend/src/de/steamwar/routes/Routes.kt b/WebsiteBackend/src/de/steamwar/routes/Routes.kt index eb5444af..84288513 100644 --- a/WebsiteBackend/src/de/steamwar/routes/Routes.kt +++ b/WebsiteBackend/src/de/steamwar/routes/Routes.kt @@ -33,6 +33,7 @@ fun Application.configureRoutes() { configurePage() configureSchematic() configureAuth() + configureAuditLog() } } } \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/Schematic.kt b/WebsiteBackend/src/de/steamwar/routes/Schematic.kt index d1ada3fa..0e8ef701 100644 --- a/WebsiteBackend/src/de/steamwar/routes/Schematic.kt +++ b/WebsiteBackend/src/de/steamwar/routes/Schematic.kt @@ -34,9 +34,7 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import kotlinx.serialization.Serializable import java.io.BufferedInputStream -import java.io.ByteArrayInputStream import java.io.DataInputStream -import java.io.InputStream import java.security.MessageDigest import java.time.Duration import java.time.Instant @@ -49,16 +47,6 @@ data class ResponseSchematic(val name: String, val id: Int, val type: String?, v constructor(node: SchematicNode) : this(node.name, node.getId(), node.schemtype?.name(), node.owner, node.item, node.lastUpdate.time, node.rank, node.replaceColor(), node.allowReplay()) } -@Serializable -data class ResponseSchematicLong(val members: List, val path: String, val schem: ResponseSchematic) { - constructor(node: SchematicNode, path: String): this(NodeMember.getNodeMembers(node.getId()).map { ResponseUser.get(it.member) }, path, ResponseSchematic(node)) -} - -@Serializable -data class ResponseSchematicList(val breadcrumbs: List, val schematics: List, val players: Map) { - constructor(schematics: List, breadcrumbs: List) : this(breadcrumbs, schematics, schematics.map { it.owner }.distinct().map { ResponseUser.get(it) }.associateBy { it.uuid }) -} - @Serializable data class ResponseBreadcrumb(val name: String, val id: Int) diff --git a/WebsiteBackend/src/de/steamwar/routes/UserPerms.kt b/WebsiteBackend/src/de/steamwar/routes/UserPerms.kt index 6ed092a4..1c3987a7 100644 --- a/WebsiteBackend/src/de/steamwar/routes/UserPerms.kt +++ b/WebsiteBackend/src/de/steamwar/routes/UserPerms.kt @@ -23,11 +23,17 @@ import de.steamwar.plugins.SWPermissionCheck import de.steamwar.plugins.getUser import de.steamwar.sql.SteamwarUser import de.steamwar.sql.UserPerm -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import kotlinx.serialization.Serializable +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.call +import io.ktor.server.application.install +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.put +import io.ktor.server.routing.route +import kotlinx.serialization.Serializable; @Serializable data class RespondPrefix(val name: String, val colorCode: String, val chatPrefix: String) @@ -76,7 +82,12 @@ fun Route.configureUserPerms() { val prefixs = UserPerm.prefixes[prefix]!! - call.respond(RespondUserPermsPrefix(RespondPrefix(prefix.name, prefixs.colorCode, prefixs.chatPrefix), perms)) + call.respond( + RespondUserPermsPrefix( + RespondPrefix(prefix.name, prefixs.colorCode, prefixs.chatPrefix), + perms + ) + ) } put("/prefix/{prefix}") { val (user, prefix) = call.receivePermission("prefix", isPrefix = true) ?: return@put @@ -112,7 +123,10 @@ fun Route.configureUserPerms() { } } -suspend fun ApplicationCall.receivePermission(fieldName: String = "perm", isPrefix: Boolean = false): Pair? { +suspend fun ApplicationCall.receivePermission( + fieldName: String = "perm", + isPrefix: Boolean = false +): Pair? { val user = request.getUser() if (user == null) { respond(HttpStatusCode.BadRequest)