Compare commits

...

10 Commits

Author SHA1 Message Date
a8204a181b Add API V2
Some checks failed
SteamWarCI Build failed
Signed-off-by: Chaoscaot <max@maxsp.de>
2026-04-17 18:25:42 +02:00
2208dcc0fb Fix Set Parent
All checks were successful
SteamWarCI Build successful
Signed-off-by: Chaoscaot <max@maxsp.de>
2026-04-15 18:20:07 +02:00
b466216b3a Fix BlockFormListener
All checks were successful
SteamWarCI Build successful
2026-04-13 20:33:11 +02:00
5a862b251b Add BlockFormListener
All checks were successful
SteamWarCI Build successful
2026-04-13 20:29:47 +02:00
60a82a685d Improve FightSystem REDUCED_DEBUG_INFO on Test Arena
All checks were successful
SteamWarCI Build successful
2026-04-13 20:18:43 +02:00
573b0c14ae Improve FightSystem REDUCED_DEBUG_INFO on Test Arena
All checks were successful
SteamWarCI Build successful
2026-04-13 20:03:41 +02:00
82abe7e20f Fix OutsideWincondition
All checks were successful
SteamWarCI Build successful
2026-04-05 12:34:11 +02:00
34da59714e Fix IngameListener and StartCommand
All checks were successful
SteamWarCI Build successful
2026-04-05 12:30:55 +02:00
97071165cd Fix IngameListener, OutsideWincondition, TowerGenerator
All checks were successful
SteamWarCI Build successful
2026-04-05 12:08:29 +02:00
634465fbf1 Fix BanListener inserting bedrock ips
All checks were successful
SteamWarCI Build successful
2026-04-04 12:08:17 +02:00
23 changed files with 953 additions and 77 deletions

View File

@@ -440,6 +440,13 @@ public final class GameModeConfig<M, W> {
*/
public final boolean DisableSnowMelt;
/**
* Disable ice forming
*
* @implSpec {@code false} by default
*/
public final boolean DisableIceForm;
/**
* Allow leaving the arena area as spectator
*
@@ -470,6 +477,7 @@ public final class GameModeConfig<M, W> {
BorderFromSchematic = loader.getInt("BorderFromSchematic", 21);
GroundWalkable = loader.getBoolean("GroundWalkable", true);
DisableSnowMelt = loader.getBoolean("DisableSnowMelt", false);
DisableIceForm = loader.getBoolean("DisableIceForm", false);
Leaveable = loader.getBoolean("Leaveable", false);
AllowMissiles = loader.getBoolean("AllowMissiles", !EnterStages.isEmpty());
NoFloor = loader.getBoolean("NoFloor", false);

View File

@@ -94,7 +94,7 @@ class NodeMember(id: EntityID<CompositeID>) : CompositeEntity(id) {
{ Optional.ofNullable(it?.value) })
private set
fun setParentId(id: Int?) {
fun setParentId(id: Int?) = useDb {
parent = Optional.ofNullable(id)
}

View File

@@ -198,7 +198,7 @@ class SteamwarUser(id: EntityID<Int>): 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 }

View File

@@ -66,6 +66,7 @@ class Team(id: EntityID<Int>) : 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 {

View File

@@ -79,7 +79,7 @@ class TeamTeilnahme(id: EntityID<CompositeID>) : 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

View File

@@ -33,9 +33,6 @@ import de.steamwar.fightsystem.states.FightState;
import de.steamwar.fightsystem.states.OneShotStateDependent;
import de.steamwar.fightsystem.states.StateDependentListener;
import de.steamwar.fightsystem.utils.*;
import de.steamwar.fightsystem.winconditions.Wincondition;
import de.steamwar.fightsystem.winconditions.WinconditionComparisonTimeout;
import de.steamwar.fightsystem.winconditions.Winconditions;
import de.steamwar.linkage.AbstractLinker;
import de.steamwar.linkage.SpigotLinker;
import de.steamwar.message.Message;
@@ -43,6 +40,7 @@ import de.steamwar.sql.NodeData;
import de.steamwar.sql.SchematicNode;
import lombok.Getter;
import org.bukkit.Bukkit;
import org.bukkit.GameRule;
import org.bukkit.plugin.java.JavaPlugin;
public class FightSystem extends JavaPlugin {
@@ -100,6 +98,11 @@ public class FightSystem extends JavaPlugin {
new StateDependentListener(ArenaMode.All, FightState.All, BountifulWrapper.impl.newDenyArrowPickupListener());
new OneShotStateDependent(ArenaMode.All, FightState.PreSchemSetup, () -> Fight.playSound(SWSound.BLOCK_NOTE_PLING.getSound(), 100.0f, 2.0f));
new OneShotStateDependent(ArenaMode.Test, FightState.All, WorldEditRendererCUIEditor::new);
try {
Bukkit.getWorlds().get(0).setGameRule(GameRule.REDUCED_DEBUG_INFO, ArenaMode.AntiTest.contains(Config.mode));
} catch (Exception e) {
// Ignore if failed!
}
techHider = new TechHiderWrapper();
hullHider = new HullHider();

View File

@@ -0,0 +1,44 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package de.steamwar.fightsystem.listener;
import de.steamwar.fightsystem.Config;
import de.steamwar.fightsystem.states.FightState;
import de.steamwar.fightsystem.states.StateDependentListener;
import de.steamwar.linkage.Linked;
import org.bukkit.Material;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockFormEvent;
@Linked
public class BlockFormListener implements Listener {
public BlockFormListener() {
new StateDependentListener(Config.GameModeConfig.Arena.DisableIceForm, FightState.All, this);
}
@EventHandler
public void onBlockForm(BlockFormEvent event) {
if (Config.ArenaRegion.inRegion(event.getBlock()) && event.getNewState().getType() == Material.ICE) {
event.setCancelled(true);
}
}
}

View File

@@ -22,6 +22,7 @@ package de.steamwar.towerrun.commands;
import de.steamwar.command.SWCommand;
import de.steamwar.command.TypeValidator;
import de.steamwar.linkage.Linked;
import de.steamwar.linkage.LinkedInstance;
import de.steamwar.sql.SteamwarUser;
import de.steamwar.sql.UserPerm;
import de.steamwar.towerrun.TowerRun;
@@ -30,6 +31,8 @@ import org.bukkit.entity.Player;
@Linked
public class StartCommand extends SWCommand {
@LinkedInstance
private LobbyCountdown countdown;
public StartCommand() {

View File

@@ -159,8 +159,8 @@ public class TowerGenerator {
noKeyFloors--;
if (!chestBlocks.isEmpty() && noKeyFloors < 0 && random.nextDouble() < config.keyChance) {
noKeyFloors = random.nextInt(config.maxNoKeyFloors - config.minNoKeyFloors) + config.minNoKeyFloors;
for (int i = 0; i < 2; i++) {
Container container = chestBlocks.get(random.nextInt(chestBlocks.size()));
for (int i = 0; i < 2 && !chestBlocks.isEmpty(); i++) {
Container container = chestBlocks.remove(random.nextInt(chestBlocks.size()));
keys.add(container.getLocation());
}

View File

@@ -42,6 +42,7 @@ import org.bukkit.event.entity.EntityRegainHealthEvent;
import org.bukkit.event.entity.ItemSpawnEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.scheduler.BukkitRunnable;
import java.util.*;
@@ -158,10 +159,16 @@ public class IngameListener extends GameStateBukkitListener {
public void onKeyUse(PlayerInteractEvent event) {
if (!event.hasItem()) return;
if (event.getItem().getType() != Material.LEVER) return;
event.setCancelled(true);
if (!event.hasBlock()) return;
if (event.getClickedBlock().getType() != Material.IRON_DOOR) return;
event.getPlayer().getInventory().setItemInMainHand(null);
if (event.getHand() == null) return;
event.setCancelled(true);
ItemStack itemStack = event.getItem();
itemStack.setAmount(event.getItem().getAmount() - 1);
switch (event.getHand()) {
case OFF_HAND -> event.getPlayer().getInventory().setItemInOffHand(itemStack);
case HAND -> event.getPlayer().getInventory().setItemInMainHand(itemStack);
}
event.getClickedBlock().breakNaturally();
}
@@ -223,6 +230,8 @@ public class IngameListener extends GameStateBukkitListener {
shouldMelt(block.getRelative(0, 0, -1));
}
private static final Random RANDOM = new Random();
private void shouldMelt(Block block) {
if (block.getType().isBurnable()) return;
if (block.getType().isAir()) return;
@@ -269,7 +278,9 @@ public class IngameListener extends GameStateBukkitListener {
break;
}
Pos pos = new Pos(block.getLocation().getBlockX(), block.getLocation().getBlockY(), block.getLocation().getBlockZ());
blocksToMelt.putIfAbsent(pos, time + meltingTime + 1);
int delay = meltingTime + 1 + RANDOM.nextInt(30*20)-30*10;
if (delay < 0) delay = meltingTime + 1;
blocksToMelt.putIfAbsent(pos, time + delay);
}
@EventHandler

View File

@@ -42,6 +42,10 @@ public abstract class OutsideWincondition extends WinCondition {
@EventHandler
public void onPlayerMove(PlayerMoveEvent event) {
if (event.getTo().getY() > WorldConfig.ESCAPE_HEIGHT) {
if (event.getTo().getY() > WorldConfig.ESCAPE_HEIGHT + 10 && Arrays.stream(WorldConfig.REGIONS).noneMatch(region -> region.contains(event.getTo().toVector()))) {
TowerRunPlayer tPlayer = TowerRunPlayer.get(event.getPlayer());
tPlayer.player().damage(Integer.MAX_VALUE);
}
return;
}

View File

@@ -46,7 +46,9 @@ public class BanListener extends BasicListener {
SteamwarUser user = SteamwarUser.get(player.getUniqueId());
String ip = IPSanitizer.getTrueAddress(player).getHostAddress();
if (user.isPunished(Punishment.PunishmentType.Ban)) {
if (!player.getUsername().startsWith(".")) {
BannedUserIPs.banIP(user.getId(), ip);
}
Chatter.of(event).system(PunishmentCommand.punishmentMessage(user, Punishment.PunishmentType.Ban));
return;
}

View File

@@ -47,12 +47,18 @@ data class UsernamePassword(val name: String, val password: String, val keepLogg
fun Route.configureAuth() {
route("/auth") {
val client = HttpClient(Java) {
install(ContentNegotiation) {
json()
}
delete {
call.sessions.clear<SWUserSession>()
call.respond(HttpStatusCode.NoContent)
}
configureDiscordAuth()
configurePasswordAuth()
}
}
fun Route.configurePasswordAuth() {
post {
val request = call.receive<UsernamePassword>()
@@ -68,14 +74,16 @@ fun Route.configureAuth() {
call.sessions.set(SWUserSession(user.getId()))
call.respond(ResponseUser(user))
}
}
delete {
call.sessions.clear<SWUserSession>()
call.respond(HttpStatusCode.NoContent)
fun Route.configureDiscordAuth() {
val client = HttpClient(Java) {
install(ContentNegotiation) {
json()
}
}
route("/discord") {
post {
post("/discord") {
val token = call.receiveText()
val res = client.get("https://discord.com/api/v10/oauth2/@me") {
@@ -102,6 +110,4 @@ fun Route.configureAuth() {
call.sessions.set(SWUserSession(user.getId()))
call.respond(ResponseUser(user))
}
}
}
}

View File

@@ -75,7 +75,7 @@ data class ResponseUser(
@Serializable
data class ResponseUserList(val entries: List<ResponseUser>, val rows: Long)
private fun Query.addUserFilter(
fun Query.addUserFilter(
name: String? = null,
uuid: UUID? = null,
team: Set<Int>? = null,

View File

@@ -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

View File

@@ -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()
}
}
}
}

View File

@@ -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<ResponseUser>
) {
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<SWAuthPrincipal>()?.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(
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(
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(
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
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<SWUserSession>()
call.respond(HttpStatusCode.NoContent)
}
configureDiscordAuth()
route("/legacy") {
configurePasswordAuth()
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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"))
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<SWAuthPrincipal>()
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<SWAuthPrincipal>()?.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<SWAuthPrincipal>()?.user
if (user == null) {
call.respond(HttpStatusCode.Unauthorized)
return@post
}
val file = call.receive<UploadSchematic>()
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<SWAuthPrincipal>()?.user?.let { schem.accessibleByUser(it) } ?: false)) {
respond(HttpStatusCode.Forbidden)
return null
}
return schem
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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) } })
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ResponseUser>,
val leaders: List<ResponseUser>,
val events: List<ResponseEvent>
)
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) })
})
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<SWAuthPrincipal>()
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<CreatePunishment>()
val punisher = call.principal<SWAuthPrincipal>()?.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<SWAuthPrincipal>()?.user
}
userString.toIntOrNull()?.let { return SteamwarUser.byId(it) }
userString.let { catchException { UUID.fromString(it) } }?.let { return SteamwarUser.get(it) }
return null
}