From a8204a181b9d79d7f503a57926530f57de4411c4 Mon Sep 17 00:00:00 2001 From: Chaoscaot Date: Fri, 17 Apr 2026 18:25:42 +0200 Subject: [PATCH] Add API V2 Signed-off-by: Chaoscaot --- .../SQL/src/de/steamwar/sql/SteamwarUser.kt | 2 +- CommonCore/SQL/src/de/steamwar/sql/Team.kt | 1 + .../SQL/src/de/steamwar/sql/TeamTeilnahme.kt | 2 +- WebsiteBackend/src/de/steamwar/routes/Auth.kt | 96 +++--- WebsiteBackend/src/de/steamwar/routes/Data.kt | 2 +- .../src/de/steamwar/routes/EventFights.kt | 3 + .../src/de/steamwar/routes/Routes.kt | 17 ++ .../src/de/steamwar/routes/Schematic.kt | 80 +++-- .../src/de/steamwar/routes/v2/Auth.kt | 46 +++ .../src/de/steamwar/routes/v2/GameModes.kt | 55 ++++ .../src/de/steamwar/routes/v2/Schematics.kt | 210 ++++++++++++++ .../src/de/steamwar/routes/v2/SteamWar.kt | 60 ++++ .../src/de/steamwar/routes/v2/Teams.kt | 87 ++++++ .../src/de/steamwar/routes/v2/Users.kt | 274 ++++++++++++++++++ 14 files changed, 868 insertions(+), 67 deletions(-) create mode 100644 WebsiteBackend/src/de/steamwar/routes/v2/Auth.kt create mode 100644 WebsiteBackend/src/de/steamwar/routes/v2/GameModes.kt create mode 100644 WebsiteBackend/src/de/steamwar/routes/v2/Schematics.kt create mode 100644 WebsiteBackend/src/de/steamwar/routes/v2/SteamWar.kt create mode 100644 WebsiteBackend/src/de/steamwar/routes/v2/Teams.kt create mode 100644 WebsiteBackend/src/de/steamwar/routes/v2/Users.kt diff --git a/CommonCore/SQL/src/de/steamwar/sql/SteamwarUser.kt b/CommonCore/SQL/src/de/steamwar/sql/SteamwarUser.kt index b135ab97..c8e01e16 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/SteamwarUser.kt +++ b/CommonCore/SQL/src/de/steamwar/sql/SteamwarUser.kt @@ -198,7 +198,7 @@ class SteamwarUser(id: EntityID): IntEntity(id) { var discordId by SteamwarUserTable.discordId - private val punishments by lazy { Punishment.getPunishmentsOfPlayer(id.value) } + val punishments by lazy { Punishment.getPunishmentsOfPlayer(id.value) } private val perms by lazy { UserPerm.getPerms(id.value) } private val prefix by lazy { perms.firstOrNull { UserPerm.prefixes.containsKey(it) }?.let { UserPerm.prefixes[it]} ?: UserPerm.emptyPrefix } diff --git a/CommonCore/SQL/src/de/steamwar/sql/Team.kt b/CommonCore/SQL/src/de/steamwar/sql/Team.kt index 66614ad2..42268f0f 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/Team.kt +++ b/CommonCore/SQL/src/de/steamwar/sql/Team.kt @@ -66,6 +66,7 @@ class Team(id: EntityID) : IntEntity(id) { var deleted by TeamTable.deleted private set val members by lazy { useDb { SteamwarUserTable.select(SteamwarUserTable.id).where { SteamwarUserTable.team eq teamId }.map { it[SteamwarUserTable.id].value } } } + val membersUser by lazy { useDb { SteamwarUser.find { SteamwarUserTable.team eq teamId }.toList() } } fun size() = useDb { SteamwarUser.find { SteamwarUserTable.team eq teamId }.count().toInt() } fun disband(user: SteamwarUser) = useDb { diff --git a/CommonCore/SQL/src/de/steamwar/sql/TeamTeilnahme.kt b/CommonCore/SQL/src/de/steamwar/sql/TeamTeilnahme.kt index 1fd92e3c..74dcc5a2 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/TeamTeilnahme.kt +++ b/CommonCore/SQL/src/de/steamwar/sql/TeamTeilnahme.kt @@ -79,7 +79,7 @@ class TeamTeilnahme(id: EntityID) : CompositeEntity(id) { @JvmStatic fun getEvents(teamId: Int) = useDb { - find { TeamTeilnahmeTable.teamId eq teamId }.map { Event.byId(it.eventId.value) }.toSet() + find { TeamTeilnahmeTable.teamId eq teamId }.mapNotNull { Event.byId(it.eventId.value) }.toSet() } @JvmStatic diff --git a/WebsiteBackend/src/de/steamwar/routes/Auth.kt b/WebsiteBackend/src/de/steamwar/routes/Auth.kt index 4710bd5b..c7234bad 100644 --- a/WebsiteBackend/src/de/steamwar/routes/Auth.kt +++ b/WebsiteBackend/src/de/steamwar/routes/Auth.kt @@ -47,61 +47,67 @@ data class UsernamePassword(val name: String, val password: String, val keepLogg fun Route.configureAuth() { route("/auth") { - val client = HttpClient(Java) { - install(ContentNegotiation) { - json() - } - } - - post { - val request = call.receive() - - SteamwarUser.clear() - val user = SteamwarUser.get(request.name) - val valid = user?.verifyPassword(request.password) ?: false - - if (!valid) { - call.respond(HttpStatusCode.Forbidden, ResponseError("Invalid username or password", "invalid")) - return@post - } - - call.sessions.set(SWUserSession(user.getId())) - call.respond(ResponseUser(user)) - } delete { call.sessions.clear() call.respond(HttpStatusCode.NoContent) } - route("/discord") { - post { - val token = call.receiveText() + configureDiscordAuth() + configurePasswordAuth() + } +} - val res = client.get("https://discord.com/api/v10/oauth2/@me") { - headers { - append("Authorization", "Bearer $token") - } - } - val resJson = Json.parseToJsonElement(res.bodyAsText()).jsonObject - val discordId = resJson["user"]?.jsonObject["id"]?.jsonPrimitive?.content +fun Route.configurePasswordAuth() { + post { + val request = call.receive() - if (discordId == null) { - call.respond(HttpStatusCode.Forbidden, ResponseError("Invalid Discord token", "invalid")) - return@post - } + SteamwarUser.clear() + val user = SteamwarUser.get(request.name) + val valid = user?.verifyPassword(request.password) ?: false - SteamwarUser.clear() - val user = SteamwarUser.get(discordId.toLong()) + if (!valid) { + call.respond(HttpStatusCode.Forbidden, ResponseError("Invalid username or password", "invalid")) + return@post + } - if (user == null) { - call.respond(HttpStatusCode.Forbidden, ResponseError("Discord account not linked", "not_linked")) - return@post - } + call.sessions.set(SWUserSession(user.getId())) + call.respond(ResponseUser(user)) + } +} - call.sessions.set(SWUserSession(user.getId())) - call.respond(ResponseUser(user)) - } +fun Route.configureDiscordAuth() { + val client = HttpClient(Java) { + install(ContentNegotiation) { + json() } } -} \ No newline at end of file + + post("/discord") { + val token = call.receiveText() + + val res = client.get("https://discord.com/api/v10/oauth2/@me") { + headers { + append("Authorization", "Bearer $token") + } + } + val resJson = Json.parseToJsonElement(res.bodyAsText()).jsonObject + val discordId = resJson["user"]?.jsonObject["id"]?.jsonPrimitive?.content + + if (discordId == null) { + call.respond(HttpStatusCode.Forbidden, ResponseError("Invalid Discord token", "invalid")) + return@post + } + + SteamwarUser.clear() + val user = SteamwarUser.get(discordId.toLong()) + + if (user == null) { + call.respond(HttpStatusCode.Forbidden, ResponseError("Discord account not linked", "not_linked")) + return@post + } + + call.sessions.set(SWUserSession(user.getId())) + call.respond(ResponseUser(user)) + } +} diff --git a/WebsiteBackend/src/de/steamwar/routes/Data.kt b/WebsiteBackend/src/de/steamwar/routes/Data.kt index e52f72aa..ed3693dd 100644 --- a/WebsiteBackend/src/de/steamwar/routes/Data.kt +++ b/WebsiteBackend/src/de/steamwar/routes/Data.kt @@ -75,7 +75,7 @@ data class ResponseUser( @Serializable data class ResponseUserList(val entries: List, val rows: Long) -private fun Query.addUserFilter( +fun Query.addUserFilter( name: String? = null, uuid: UUID? = null, team: Set? = null, diff --git a/WebsiteBackend/src/de/steamwar/routes/EventFights.kt b/WebsiteBackend/src/de/steamwar/routes/EventFights.kt index 82569d40..fd379025 100644 --- a/WebsiteBackend/src/de/steamwar/routes/EventFights.kt +++ b/WebsiteBackend/src/de/steamwar/routes/EventFights.kt @@ -27,6 +27,7 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import kotlinx.serialization.Serializable +import org.jetbrains.exposed.v1.core.ResultRow import java.sql.Timestamp import java.time.Instant @@ -60,6 +61,8 @@ data class ResponseEventFight( @Serializable data class ResponseTeam(val id: Int, val name: String, val kuerzel: String, val color: String) { constructor(team: Team) : this(team.teamId, team.teamName, team.teamKuerzel, team.teamColor) + + constructor(row: ResultRow) : this(row[TeamTable.id].value, row[TeamTable.name], row[TeamTable.kuerzel], row[TeamTable.color]) } @Serializable diff --git a/WebsiteBackend/src/de/steamwar/routes/Routes.kt b/WebsiteBackend/src/de/steamwar/routes/Routes.kt index 84288513..07ebbe11 100644 --- a/WebsiteBackend/src/de/steamwar/routes/Routes.kt +++ b/WebsiteBackend/src/de/steamwar/routes/Routes.kt @@ -19,6 +19,12 @@ package de.steamwar.routes +import de.steamwar.routes.v2.configureAuthV2 +import de.steamwar.routes.v2.configureGameModeRoutes +import de.steamwar.routes.v2.configureSchematicsV2Route +import de.steamwar.routes.v2.configureSteamWarRoute +import de.steamwar.routes.v2.configureTeamRoutes +import de.steamwar.routes.v2.configureUsersRouteV2 import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.routing.* @@ -34,6 +40,17 @@ fun Application.configureRoutes() { configureSchematic() configureAuth() configureAuditLog() + route("/v2") { + configureAuditLog() + configurePage() + configureEventsRoute() + configureAuthV2() + configureTeamRoutes() + configureSteamWarRoute() + configureUsersRouteV2() + configureSchematicsV2Route() + configureGameModeRoutes() + } } } } \ 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 0e8ef701..81b97784 100644 --- a/WebsiteBackend/src/de/steamwar/routes/Schematic.kt +++ b/WebsiteBackend/src/de/steamwar/routes/Schematic.kt @@ -43,8 +43,31 @@ import java.util.* import java.util.zip.GZIPInputStream @Serializable -data class ResponseSchematic(val name: String, val id: Int, val type: String?, val owner: Int, val item: String, val lastUpdate: Long, val rank: Int, val replaceColor: Boolean, val allowReplay: Boolean) { - constructor(node: SchematicNode) : this(node.name, node.getId(), node.schemtype?.name(), node.owner, node.item, node.lastUpdate.time, node.rank, node.replaceColor(), node.allowReplay()) +data class ResponseSchematic( + val name: String, + val id: Int, + val type: String?, + val owner: Int, + val item: String, + val lastUpdate: Long, + val replaceColor: Boolean, + val allowReplay: Boolean, + val members: List +) { + constructor(node: SchematicNode) : this( + node.name, + node.getId(), + node.schemtype.name(), + node.owner, + node.item, + node.lastUpdate.time, + node.replaceColor(), + node.allowReplay(), + node.members.map { + ResponseUser( + SteamwarUser.byId(it.member)!! + ) + }) } @Serializable @@ -72,9 +95,12 @@ fun Route.configureSchematic() { val node = call.receiveSchematic() ?: return@get val user = call.principal()?.user - if(user != null && !node.accessibleByUser(user)) { + if (user != null && !node.accessibleByUser(user)) { call.respond(HttpStatusCode.Forbidden) - SWException.log("User ${user.userName} tried to download schematic ${node.name} without permission", user.id.toString()) + SWException.log( + "User ${user.userName} tried to download schematic ${node.name} without permission", + user.id.toString() + ) return@get } @@ -83,8 +109,15 @@ fun Route.configureSchematic() { return@get } - call.response.header("Content-Disposition", "attachment; filename=\"${node.name}${data.nodeFormat.fileEnding}\"") - call.respondBytes(data.schemData(false).readAllBytes(), contentType = ContentType.Application.OctetStream, status = HttpStatusCode.OK) + call.response.header( + "Content-Disposition", + "attachment; filename=\"${node.name}${data.nodeFormat.fileEnding}\"" + ) + call.respondBytes( + data.schemData(false).readAllBytes(), + contentType = ContentType.Application.OctetStream, + status = HttpStatusCode.OK + ) } get("/info") { val node = call.receiveSchematic() ?: return@get @@ -100,18 +133,22 @@ fun Route.configureSchematic() { val schemName = file.name.substringBeforeLast(".") if (SchematicNode.invalidSchemName(arrayOf(schemName))) { - call.respond(HttpStatusCode.BadRequest, ResponseError( - error = "INVALID_NAME" - )) + call.respond( + HttpStatusCode.BadRequest, ResponseError( + error = "INVALID_NAME" + ) + ) return@post } val schemType = file.name.substringAfterLast(".") if (schemType != "schem" && schemType != "schematic") { - call.respond(HttpStatusCode.BadRequest, ResponseError( - error = "INVALID_SUFFIX" - )) + call.respond( + HttpStatusCode.BadRequest, ResponseError( + error = "INVALID_SUFFIX" + ) + ) return@post } @@ -146,22 +183,27 @@ fun Route.configureSchematic() { .value if (fawe.equals("2.12.3-SNAPSHOT")) { - SWException.log("Schematic with Bugged Version Uploaded", """ + SWException.log( + "Schematic with Bugged Version Uploaded", """ Schematic=$schemName User=${user.userName} Id=${user.id} - """.trimIndent()) + """.trimIndent() + ) } - } catch (_: Exception) {} + } catch (_: Exception) { + } } NodeData.saveFromStream(node, content.inputStream(), version) call.respond(ResponseSchematic(node)) } catch (e: Exception) { - call.respond(HttpStatusCode.BadRequest, ResponseError( - error = e.message ?: "GENERIC", code = "UPLOAD_ERROR" - )) + call.respond( + HttpStatusCode.BadRequest, ResponseError( + error = e.message ?: "GENERIC", code = "UPLOAD_ERROR" + ) + ) } } } @@ -178,7 +220,7 @@ suspend fun ApplicationCall.receiveSchematic(fieldName: String = "code", delete: return null } - if(dl.timestamp.toInstant().plus(Duration.of(5, ChronoUnit.MINUTES)).isBefore(Instant.now())) { + if (dl.timestamp.toInstant().plus(Duration.of(5, ChronoUnit.MINUTES)).isBefore(Instant.now())) { respond(HttpStatusCode.Gone) return null } diff --git a/WebsiteBackend/src/de/steamwar/routes/v2/Auth.kt b/WebsiteBackend/src/de/steamwar/routes/v2/Auth.kt new file mode 100644 index 00000000..82771325 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/v2/Auth.kt @@ -0,0 +1,46 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2026 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.v2 + +import de.steamwar.plugins.SWUserSession +import de.steamwar.routes.configureDiscordAuth +import de.steamwar.routes.configurePasswordAuth +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.route +import io.ktor.server.sessions.clear +import io.ktor.server.sessions.sessions + +fun Route.configureAuthV2() { + route("/auth") { + delete { + call.sessions.clear() + call.respond(HttpStatusCode.NoContent) + } + + configureDiscordAuth() + route("/legacy") { + configurePasswordAuth() + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/v2/GameModes.kt b/WebsiteBackend/src/de/steamwar/routes/v2/GameModes.kt new file mode 100644 index 00000000..e24e5f5d --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/v2/GameModes.kt @@ -0,0 +1,55 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2026 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.v2 + +import de.steamwar.ResponseError +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +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 org.bspfsystems.yamlconfiguration.file.YamlConfiguration +import java.io.File +import kotlin.io.nameWithoutExtension + +fun Route.configureGameModeRoutes() { + route("/gamemodes") { + get { + call.respond( + File("/configs/GameModes/").listFiles()!! + .filter { it.name.endsWith(".yml") && !it.name.endsWith(".kits.yml") } + .map { it.nameWithoutExtension }) + } + get("/{gamemode}/maps") { + val gamemode = call.parameters["gamemode"] + if (gamemode == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid gamemode")) + return@get + } + val file = File("/configs/GameModes/$gamemode.yml") + if (!file.exists()) { + call.respond(HttpStatusCode.NotFound, ResponseError("Gamemode not found")) + return@get + } + call.respond(YamlConfiguration.loadConfiguration(file).getStringList("Server.Maps")) + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/v2/Schematics.kt b/WebsiteBackend/src/de/steamwar/routes/v2/Schematics.kt new file mode 100644 index 00000000..6a89b626 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/v2/Schematics.kt @@ -0,0 +1,210 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2026 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.v2 + +import com.sun.tools.jdeprscan.Main.call +import de.steamwar.ResponseError +import de.steamwar.plugins.SWAuthPrincipal +import de.steamwar.plugins.SWPermissionCheck +import de.steamwar.routes.ResponseSchematic +import de.steamwar.routes.ResponseSchematicType +import de.steamwar.routes.UploadSchematic +import de.steamwar.routes.nbt +import de.steamwar.routes.receiveSchematic +import de.steamwar.sql.NodeData +import de.steamwar.sql.NodeData.SchematicFormat +import de.steamwar.sql.NodeDownload +import de.steamwar.sql.SWException +import de.steamwar.sql.SchematicNode +import de.steamwar.sql.SchematicType +import dev.dewy.nbt.tags.collection.CompoundTag +import io.ktor.http.ContentType +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.auth.authentication +import io.ktor.server.auth.principal +import io.ktor.server.request.receive +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.response.respondBytes +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import java.io.BufferedInputStream +import java.io.DataInputStream +import java.util.Base64 +import java.util.zip.GZIPInputStream + +data class ListedSchematicNode(val name: String, val id: Int, val type: String) + +fun Route.configureSchematicsV2Route() { + route("/schematics") { + get("/types") { + call.respond(SchematicType.values().filter { !it.check() } + .map { ResponseSchematicType(it.name(), it.toDB()) }) + } + + get("/list/{path...}") { + val path = call.parameters.getAll("path")?.joinToString("/") ?: "/" + + val user = call.authentication.principal() + if (user == null) { + call.respond(HttpStatusCode.Unauthorized) + return@get + } + + val node = SchematicNode.getNodeFromPath(user.user, path) + call.respond(SchematicNode.list(user.user, node?.id?.value).map { ListedSchematicNode(it.name, it.id.value, it.schemtype.toDB()) }) + } + + get("/download/{code}") { + val node = call.receiveSchematic() ?: return@get + + val user = call.principal()?.user + if(user != null && !node.accessibleByUser(user)) { + call.respond(HttpStatusCode.Forbidden) + SWException.log("User ${user.userName} tried to download schematic ${node.name} without permission", user.id.toString()) + return@get + } + + val data = NodeData.getLatest(node) ?: run { + call.respond(HttpStatusCode.InternalServerError) + return@get + } + + call.response.header("Content-Disposition", "attachment; filename=\"${node.name}${data.nodeFormat.fileEnding}\"") + call.respondBytes(data.schemData(false).readAllBytes(), contentType = ContentType.Application.OctetStream, status = HttpStatusCode.OK) + } + + post("/upload") { + val user = call.principal()?.user + if (user == null) { + call.respond(HttpStatusCode.Unauthorized) + return@post + } + + val file = call.receive() + val schemName = file.name.substringBeforeLast(".") + + if (SchematicNode.invalidSchemName(arrayOf(schemName))) { + call.respond(HttpStatusCode.BadRequest, ResponseError( + error = "INVALID_NAME" + )) + return@post + } + + val schemType = file.name.substringAfterLast(".") + + if (schemType != "schem" && schemType != "schematic") { + call.respond(HttpStatusCode.BadRequest, ResponseError( + error = "INVALID_SUFFIX" + )) + return@post + } + + var node = SchematicNode.getSchematicNode(user.getId(), schemName, null as Int?) + if (node == null) { + node = SchematicNode.createSchematic(user.getId(), schemName, null) + } + + try { + val content = Base64.getDecoder().decode(file.content) + + var schem = nbt.fromStream(DataInputStream(BufferedInputStream(GZIPInputStream(content.inputStream())))) + + if (schem.size() == 1) schem = schem.first() as CompoundTag + + val version = schem.let { + if (it.contains("Materials")) + return@let SchematicFormat.MCEDIT + else if (it.contains("Blocks")) + return@let SchematicFormat.SPONGE_V3 + else + return@let SchematicFormat.SPONGE_V2 + } + + if (version == SchematicFormat.SPONGE_V3) { + try { + val fawe = schem.getCompound("Metadata") + .getCompound("WorldEdit") + .getString("Version") + .value + + if (fawe.equals("2.12.3-SNAPSHOT")) { + SWException.log("Schematic with Bugged Version Uploaded", """ + Schematic=$schemName + User=${user.userName} + Id=${user.id} + """.trimIndent()) + } + } catch (_: Exception) {} + } + + NodeData.saveFromStream(node, content.inputStream(), version) + + call.respond(ResponseSchematic(node)) + } catch (e: Exception) { + call.respond(HttpStatusCode.BadRequest, ResponseError( + error = e.message ?: "GENERIC", code = "UPLOAD_ERROR" + )) + } + } + route("/{id}") { + install(SWPermissionCheck) { + mustAuth = true + } + + get { + val node = call.receiveSchem() ?: return@get + call.respond(ResponseSchematic(node)) + } + + post("/download") { + val node = call.receiveSchem() ?: return@post + + call.respond(HttpStatusCode.OK, NodeDownload.getLink(node)) + } + } + } +} + +suspend fun ApplicationCall.receiveSchem(): SchematicNode? { + val schemId = parameters["id"]?.toIntOrNull() + if (schemId == null) { + respond(HttpStatusCode.BadRequest) + return null + } + + val schem = SchematicNode.getSchematicNode(schemId) + if (schem == null) { + respond(HttpStatusCode.NotFound) + return null + } + + if (!(principal()?.user?.let { schem.accessibleByUser(it) } ?: false)) { + respond(HttpStatusCode.Forbidden) + return null + } + + return schem +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/v2/SteamWar.kt b/WebsiteBackend/src/de/steamwar/routes/v2/SteamWar.kt new file mode 100644 index 00000000..e33d28ca --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/v2/SteamWar.kt @@ -0,0 +1,60 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2026 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.v2 + +import de.steamwar.ResponseError +import de.steamwar.routes.ResponseUser +import de.steamwar.sql.SteamwarUser +import de.steamwar.sql.UserPerm +import de.steamwar.util.fetchData +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +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 java.net.InetSocketAddress + +fun Route.configureSteamWarRoute() { + route("/steamwar") { + get { + try { + val server = fetchData(InetSocketAddress("steamwar.de", 25565), 100) + call.respond(server) + } catch (e: Exception) { + e.printStackTrace() + call.respond(HttpStatusCode.InternalServerError, ResponseError(e.message ?: "Unknown error")) + return@get + } + } + + 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(it) } }) + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/v2/Teams.kt b/WebsiteBackend/src/de/steamwar/routes/v2/Teams.kt new file mode 100644 index 00000000..52e38a9d --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/v2/Teams.kt @@ -0,0 +1,87 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2026 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.v2 + +import de.steamwar.routes.ResponseEvent +import de.steamwar.routes.ResponseTeam +import de.steamwar.routes.ResponseUser +import de.steamwar.sql.SteamwarUser +import de.steamwar.sql.Team +import de.steamwar.sql.TeamTable +import de.steamwar.sql.TeamTeilnahme +import de.steamwar.sql.internal.useDb +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +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.like +import org.jetbrains.exposed.v1.jdbc.Query +import org.jetbrains.exposed.v1.jdbc.andWhere +import org.jetbrains.exposed.v1.jdbc.selectAll + +fun Query.addTeamFilter(teamName: String?, teamKuerzel: String?): Query { + teamName?.let { andWhere { TeamTable.name like "%$it%" } } + teamKuerzel?.let { andWhere { TeamTable.kuerzel like "%$it%" } } + return this +} + +fun Route.configureTeamRoutes() { + route("/teams") { + get { + val teamName = call.request.queryParameters["name"] + val teamKuerzel = call.request.queryParameters["kuerzel"] + + val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100 + val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 0 + + call.respond(useDb { + TeamTable.selectAll().addTeamFilter(teamName, teamKuerzel).limit(limit).offset((page * limit).toLong()) + .map { ResponseTeam(it) } + }) + } + + get("/{team}") { + @Serializable + data class TeamResponse( + val team: ResponseTeam, + val members: List, + val leaders: List, + val events: List + ) + + val team = call.parameters["team"]?.toIntOrNull()?.let { Team.byId(it) } + if (team == null) { + call.respond(HttpStatusCode.NotFound) + return@get + } + + call.respond(useDb { + TeamResponse( + ResponseTeam(team), + team.membersUser.map { ResponseUser(it) }, + team.membersUser.filter { it.leader }.map { ResponseUser(it) }, + TeamTeilnahme.getEvents(team.teamId).map { ResponseEvent(it) }) + }) + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/v2/Users.kt b/WebsiteBackend/src/de/steamwar/routes/v2/Users.kt new file mode 100644 index 00000000..5201fd91 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/v2/Users.kt @@ -0,0 +1,274 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2026 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.v2 + +import de.steamwar.ResponseError +import de.steamwar.data.getCachedSkin +import de.steamwar.plugins.SWAuthPrincipal +import de.steamwar.plugins.SWPermissionCheck +import de.steamwar.routes.ResponseTeam +import de.steamwar.routes.ResponseUser +import de.steamwar.routes.ResponseUserList +import de.steamwar.routes.UserStats +import de.steamwar.routes.addUserFilter +import de.steamwar.routes.catchException +import de.steamwar.sql.Punishment +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 +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.auth.principal +import io.ktor.server.request.receive +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.response.respondFile +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.put +import io.ktor.server.routing.route +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.v1.jdbc.selectAll +import java.sql.Timestamp +import java.time.Instant +import java.util.UUID + +@Serializable +data class ResponsePunishment( + val id: Int, + val type: Punishment.PunishmentType, + val reason: String, + val issuer: ResponseUser, + val startTime: Long, + val endTime: Long, + val perma: Boolean, + val active: Boolean +) { + constructor(punishment: Punishment) : this( + punishment.id.value, + punishment.type, + punishment.reason, + ResponseUser(SteamwarUser.byId(punishment.punisher)!!), + punishment.startTime.toInstant().toEpochMilli(), + punishment.endTime.toInstant().toEpochMilli(), + punishment.perma, + punishment.isCurrent() + ) +} + +@Serializable +data class CreatePunishment( + val type: Punishment.PunishmentType, + val reason: String, + val perma: Boolean, + val endTime: Long +) + +fun Route.configureUsersRouteV2() { + get("/users/{user}/skin") { + val user = call.receiveUser() + if (user == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid user")) + return@get + } + + val skin = getCachedSkin(user.uuid.toString()) + call.response.header("X-Cache", if (skin.second) "HIT" else "MISS") + call.response.header("Cache-Control", "public, max-age=604800") + call.respondFile(skin.first) + } + get("/users/{user}/stats") { + val user = call.receiveUser() + if (user == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid user")) + return@get + } + + val authUser = call.principal() + + if (authUser == null || !(authUser.user.id == user.id || authUser.user.hasPerm(UserPerm.MODERATION))) { + call.respond(HttpStatusCode.Forbidden) + return@get + } + + call.respond(UserStats(user)) + } + route("/users") { + install(SWPermissionCheck) { + permission = UserPerm.MODERATION + } + get { + 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() + ) + } + ) + } + + route("/{user}") { + get { + @Serializable + data class UserResponse(val user: ResponseUser, val team: ResponseTeam) + + val user = call.receiveUser() + if (user == null) { + call.respond(HttpStatusCode.NotFound) + return@get + } + + call.respond( + useDb { + UserResponse(ResponseUser(user), ResponseTeam(Team.byId(user.team))) + } + ) + } + + put("/prefix/{prefix}") { + val user = call.receiveUser() + if (user == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid user")) + return@put + } + + val prefix = call.parameters["prefix"]?.let { name -> UserPerm.entries.find { it.name == name } } + if (prefix == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid prefix")) + return@put + } + + user.perms().filter { it.name.startsWith("PREFIX_") }.forEach { + UserPerm.removePerm(user, it) + } + + if (prefix != UserPerm.PREFIX_NONE) { + UserPerm.addPerm(user, prefix) + } + call.respond(HttpStatusCode.Accepted) + } + + route("/perms") { + get { + val user = call.receiveUser() + if (user == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid user")) + return@get + } + + call.respond(user.perms()) + } + route("/{perm}") { + put { + val user = call.receiveUser() + if (user == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid user")) + return@put + } + + val perm = call.parameters["perm"]?.let { name -> UserPerm.entries.find { it.name == name } } + if (perm == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid perm")) + return@put + } + + UserPerm.addPerm(user, perm) + call.respond(HttpStatusCode.Accepted) + } + delete { + val user = call.receiveUser() + if (user == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid user")) + return@delete + } + + val perm = call.parameters["perm"]?.let { name -> UserPerm.entries.find { it.name == name } } + if (perm == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid perm")) + return@delete + } + + UserPerm.removePerm(user, perm) + call.respond(HttpStatusCode.Accepted) + } + } + } + } + + route("/punishments") { + get { + val user = call.receiveUser() + if (user == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid user")) + return@get + } + + val punishments = user.punishments.toList() + + call.respond(punishments.map { ResponsePunishment(it.second) }) + } + post { + val user = call.receiveUser() + if (user == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid user")) + return@post + } + + val punishment = call.receive() + val punisher = call.principal()?.user ?: return@post + + user.punish(punishment.type, Timestamp.from(Instant.ofEpochMilli(punishment.endTime)), punishment.reason, punisher.getId(), punishment.perma) + + call.respond(HttpStatusCode.Accepted) + } + } + } +} + +fun ApplicationCall.receiveUser(): SteamwarUser? { + val userString = parameters["user"] ?: return null + + if (userString == "me") { + return principal()?.user + } + + userString.toIntOrNull()?.let { return SteamwarUser.byId(it) } + userString.let { catchException { UUID.fromString(it) } }?.let { return SteamwarUser.get(it) } + return null +} \ No newline at end of file