diff --git a/CommonCore/SQL/src/de/steamwar/sql/Event.java b/CommonCore/SQL/src/de/steamwar/sql/Event.java index 31c220ac..a92cc9f2 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/Event.java +++ b/CommonCore/SQL/src/de/steamwar/sql/Event.java @@ -21,6 +21,7 @@ package de.steamwar.sql; import de.steamwar.sql.internal.Field; import de.steamwar.sql.internal.SelectStatement; +import de.steamwar.sql.internal.Statement; import de.steamwar.sql.internal.Table; import lombok.AllArgsConstructor; import lombok.Getter; @@ -42,6 +43,11 @@ public class Event { private static final SelectStatement byId = table.select(Table.PRIMARY); private static final SelectStatement byName = table.select("eventName"); private static final SelectStatement byComing = new SelectStatement<>(table, "SELECT * FROM Event WHERE Start > now()"); + private static final SelectStatement all = new SelectStatement<>(table, "SELECT * FROM Event"); + + private static final Statement create = table.insertFields(true, "eventName", "deadline", "start", "end", "maximumTeamMembers", "publicSchemsOnly"); + private static final Statement update = table.update(Table.PRIMARY, "eventName", "deadline", "start", "end", "schemType", "maximumTeamMembers", "publicSchemsOnly"); + private static final Statement delete = table.delete(Table.PRIMARY); private static Event current = null; @@ -53,6 +59,14 @@ public class Event { return current; } + public static List getAll(){ + return all.listSelect(); + } + + public static Event create(String eventName, Timestamp start, Timestamp end){ + return get(create.insertGetKey(eventName, start, start, end, 5, false, false)); + } + public static Event get(int eventID){ return byId.select(eventID); } @@ -87,16 +101,10 @@ public class Event { private final SchematicType schemType; @Field private final boolean publicSchemsOnly; - @Deprecated - @Field - private final boolean spectateSystem; public boolean publicSchemsOnly() { return publicSchemsOnly; } - public boolean spectateSystem(){ - return spectateSystem; - } public SchematicType getSchematicType() { return schemType; @@ -106,4 +114,12 @@ public class Event { Instant now = Instant.now(); return now.isAfter(start.toInstant()) && now.isBefore(end.toInstant()); } + + public void update(String eventName, Timestamp deadline, Timestamp start, Timestamp end, SchematicType schemType, int maximumTeamMembers, boolean publicSchemsOnly) { + update.update(eventName, deadline, start, end, schemType, maximumTeamMembers, publicSchemsOnly, eventID); + } + + public void delete() { + delete.update(eventID); + } } diff --git a/CommonCore/SQL/src/de/steamwar/sql/EventFight.java b/CommonCore/SQL/src/de/steamwar/sql/EventFight.java index 623e383a..cd7352b3 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/EventFight.java +++ b/CommonCore/SQL/src/de/steamwar/sql/EventFight.java @@ -25,6 +25,7 @@ import de.steamwar.sql.internal.Statement; import de.steamwar.sql.internal.Table; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.Setter; import java.sql.Timestamp; import java.util.*; @@ -42,6 +43,11 @@ public class EventFight implements Comparable { private static final Statement setResult = table.update(Table.PRIMARY, "Ergebnis"); private static final Statement setFight = table.update(Table.PRIMARY, "Fight"); + private static final Statement create = table.insertAll(true); + private static final Statement update = table.update(Table.PRIMARY, "startTime", "spielModus", "map", "teamBlue", "teamRed", "spectatePort"); + private static final Statement delete = table.delete(Table.PRIMARY); + + @Getter private static final Queue fights = new PriorityQueue<>(); public static EventFight get(int fightID) { @@ -57,8 +63,8 @@ public class EventFight implements Comparable { return event.listSelect(eventID); } - public static Queue getFights() { - return fights; + public static EventFight create(int event, Timestamp from, String spielmodus, String map, int blueTeam, int redTeam, Integer spectatePort) { + return get(create.insertGetKey(event, from, spielmodus, map, blueTeam, redTeam, spectatePort)); } @Getter @@ -68,27 +74,29 @@ public class EventFight implements Comparable { @Field(keys = {Table.PRIMARY}, autoincrement = true) private final int fightID; @Getter + @Setter @Field private Timestamp startTime; @Getter + @Setter @Field - private final String spielmodus; + private String spielmodus; @Getter + @Setter @Field - private final String map; + private String map; @Getter + @Setter @Field - private final int teamBlue; + private int teamBlue; @Getter + @Setter @Field - private final int teamRed; + private int teamRed; @Getter - @Field - @Deprecated - private final int kampfleiter; - @Getter - @Field - private final int spectatePort; + @Setter + @Field(nullable = true) + private int spectatePort; @Getter @Field(def = "0") private int ergebnis; @@ -133,4 +141,18 @@ public class EventFight implements Comparable { public int compareTo(EventFight o) { return startTime.compareTo(o.startTime); } + + public void update(Timestamp startTime, String spielmodus, String map, int teamBlue, int teamRed, Integer spectatePort) { + update.update(startTime, spielmodus, map, teamBlue, teamRed, spectatePort, fightID); + this.startTime = startTime; + this.spielmodus = spielmodus; + this.map = map; + this.teamBlue = teamBlue; + this.teamRed = teamRed; + this.spectatePort = spectatePort; + } + + public void delete() { + delete.update(fightID); + } } diff --git a/CommonCore/SQL/src/de/steamwar/sql/NodeDownload.java b/CommonCore/SQL/src/de/steamwar/sql/NodeDownload.java index 666cf681..018d95d7 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/NodeDownload.java +++ b/CommonCore/SQL/src/de/steamwar/sql/NodeDownload.java @@ -20,9 +20,11 @@ package de.steamwar.sql; import de.steamwar.sql.internal.Field; +import de.steamwar.sql.internal.SelectStatement; import de.steamwar.sql.internal.Statement; import de.steamwar.sql.internal.Table; import lombok.AllArgsConstructor; +import lombok.Getter; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -38,11 +40,21 @@ public class NodeDownload { private static final Table table = new Table<>(NodeDownload.class); private static final Statement insert = table.insertFields("NodeId", "Link"); + private static final SelectStatement select = table.select("link"); + + private static final Statement delete = table.delete(Table.PRIMARY); + + public static NodeDownload get(String link) { + return select.select(link); + } + + @Getter @Field(keys = {Table.PRIMARY}) private final int nodeId; @Field private final String link; @Field(def = "CURRENT_TIMESTAMP") + @Getter private final Timestamp timestamp; public static String getLink(SchematicNode schem){ @@ -60,10 +72,15 @@ public class NodeDownload { insert.update(schem.getId(), hash); return LINK_BASE + hash; } + public static String base16encode(byte[] byteArray) { StringBuilder hexBuffer = new StringBuilder(byteArray.length * 2); for (byte b : byteArray) hexBuffer.append(HEX[(b >>> 4) & 0xF]).append(HEX[b & 0xF]); return hexBuffer.toString(); } + + public void delete() { + delete.update(nodeId); + } } diff --git a/CommonCore/SQL/src/de/steamwar/sql/NodeMember.java b/CommonCore/SQL/src/de/steamwar/sql/NodeMember.java index e25764ec..64844e94 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/NodeMember.java +++ b/CommonCore/SQL/src/de/steamwar/sql/NodeMember.java @@ -72,6 +72,10 @@ public class NodeMember { return new NodeMember(node, member, null); } + public static NodeMember getNodeMember(int node, SteamwarUser member) { + return getNodeMember(node, member.getId()); + } + public static NodeMember getNodeMember(int node, int member) { return getNodeMember.select(node, member); } diff --git a/CommonCore/SQL/src/de/steamwar/sql/Referee.java b/CommonCore/SQL/src/de/steamwar/sql/Referee.java index 281ad302..e22faf7e 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/Referee.java +++ b/CommonCore/SQL/src/de/steamwar/sql/Referee.java @@ -21,6 +21,7 @@ package de.steamwar.sql; import de.steamwar.sql.internal.Field; import de.steamwar.sql.internal.SelectStatement; +import de.steamwar.sql.internal.Statement; import de.steamwar.sql.internal.Table; import lombok.AllArgsConstructor; @@ -33,6 +34,17 @@ public class Referee { private static final Table table = new Table<>(Referee.class); private static final SelectStatement byEvent = table.selectFields("eventID"); + private static final Statement insert = table.insertAll(); + private static final Statement delete = table.delete("eventReferee"); + + public static void add(int eventID, int userID) { + insert.update(eventID, userID); + } + + public static void remove(int eventID, int userID) { + delete.update(eventID, userID); + } + public static Set get(int eventID) { return byEvent.listSelect(eventID).stream().map(referee -> referee.userID).collect(Collectors.toSet()); } diff --git a/CommonCore/SQL/src/de/steamwar/sql/SchematicNode.java b/CommonCore/SQL/src/de/steamwar/sql/SchematicNode.java index b432a709..3df740da 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/SchematicNode.java +++ b/CommonCore/SQL/src/de/steamwar/sql/SchematicNode.java @@ -443,7 +443,7 @@ public class SchematicNode { return SchemElo.getElo(this, season); } - public boolean accessibleByUser(int user) { + public boolean accessibleByUser(SteamwarUser user) { return NodeMember.getNodeMember(nodeId, user) != null; } @@ -506,6 +506,19 @@ public class SchematicNode { return builder.toString(); } + public List> generateBreadcrumbsMap(SteamwarUser user) { + List> map = new ArrayList<>(); + Optional currentNode = Optional.of(this); + if(currentNode.map(SchematicNode::isDir).orElse(false)) { + map.add(new AbstractMap.SimpleEntry<>(getName(), getId())); + } + while (currentNode.isPresent()) { + currentNode = currentNode.flatMap(schematicNode -> Optional.ofNullable(NodeMember.getNodeMember(schematicNode.getId(), effectiveOwner)).map(NodeMember::getParent).orElse(schematicNode.getOptionalParent())).map(SchematicNode::getSchematicNode); + currentNode.ifPresent(node -> map.add(0, new AbstractMap.SimpleEntry<>(node.getName(), node.getId()))); + } + return map; + } + private static final List FORBIDDEN_NAMES = Collections.unmodifiableList(Arrays.asList("public")); public static boolean invalidSchemName(String[] layers) { for (String layer : layers) { diff --git a/CommonCore/SQL/src/de/steamwar/sql/SchematicType.java b/CommonCore/SQL/src/de/steamwar/sql/SchematicType.java index 0ee483a8..62b047c8 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/SchematicType.java +++ b/CommonCore/SQL/src/de/steamwar/sql/SchematicType.java @@ -108,7 +108,7 @@ public class SchematicType { return name.toLowerCase(); } - public static SchematicType fromDB(String input){ + public static SchematicType fromDB(String input) { return fromDB.get(input.toLowerCase()); } diff --git a/CommonCore/SQL/src/de/steamwar/sql/SteamwarUser.java b/CommonCore/SQL/src/de/steamwar/sql/SteamwarUser.java index b505ab1f..8aefbd49 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/SteamwarUser.java +++ b/CommonCore/SQL/src/de/steamwar/sql/SteamwarUser.java @@ -62,6 +62,7 @@ public class SteamwarUser { private static final SelectStatement byDiscord = table.selectFields("DiscordId"); private static final SelectStatement byTeam = table.selectFields("Team"); private static final SelectStatement getUsersWithPerm = new SelectStatement<>(table, "SELECT S.* FROM UserData S JOIN UserPerm P ON S.id = P.User WHERE P.Perm = ?"); + private static final SelectStatement getAll = new SelectStatement(table, "SELECT * FROM UserData"); private static final Statement updateName = table.update(Table.PRIMARY, "UserName"); private static final Statement updatePassword = table.update(Table.PRIMARY, "Password"); @@ -370,4 +371,8 @@ public class SteamwarUser { permissions = UserPerm.getPerms(id); prefix = permissions.stream().filter(UserPerm.prefixes::containsKey).findAny().map(UserPerm.prefixes::get).orElse(UserPerm.emptyPrefix); } + + public static List getAll() { + return null; + } } diff --git a/CommonCore/SQL/src/de/steamwar/sql/UserPerm.java b/CommonCore/SQL/src/de/steamwar/sql/UserPerm.java index 0aca0319..a05fe2ad 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/UserPerm.java +++ b/CommonCore/SQL/src/de/steamwar/sql/UserPerm.java @@ -19,10 +19,7 @@ package de.steamwar.sql; -import de.steamwar.sql.internal.Field; -import de.steamwar.sql.internal.SelectStatement; -import de.steamwar.sql.internal.SqlTypeMapper; -import de.steamwar.sql.internal.Table; +import de.steamwar.sql.internal.*; import lombok.AllArgsConstructor; import lombok.Getter; @@ -68,11 +65,21 @@ public enum UserPerm { private static final Table table = new Table<>(UserPermTable.class, "UserPerm"); private static final SelectStatement getPerms = table.selectFields("user"); + private static final Statement addPerm = table.insertFields("user", "perm"); + private static final Statement removePerm = table.delete(Table.PRIMARY); public static Set getPerms(int user) { return getPerms.listSelect(user).stream().map(up -> up.perm).collect(Collectors.toSet()); } + public static void addPerm(SteamwarUser user, UserPerm perm) { + addPerm.update(user, perm); + } + + public static void removePerm(SteamwarUser user, UserPerm perm) { + removePerm.update(user, perm); + } + @Getter @AllArgsConstructor public static class Prefix { diff --git a/CommonCore/SQL/src/de/steamwar/sql/internal/Table.java b/CommonCore/SQL/src/de/steamwar/sql/internal/Table.java index ddb5adc7..e052bf26 100644 --- a/CommonCore/SQL/src/de/steamwar/sql/internal/Table.java +++ b/CommonCore/SQL/src/de/steamwar/sql/internal/Table.java @@ -83,7 +83,11 @@ public class Table { } public Statement insertAll() { - return insertFields(false, Arrays.stream(fields).map(f -> f.identifier).toArray(String[]::new)); + return insertAll(false); + } + + public Statement insertAll(boolean returnGeneratedKeys) { + return insertFields(returnGeneratedKeys, Arrays.stream(fields).map(f -> f.identifier).toArray(String[]::new)); } public Statement insertFields(String... fields) { diff --git a/WebsiteBackend/build.gradle.kts b/WebsiteBackend/build.gradle.kts new file mode 100644 index 00000000..cd077973 --- /dev/null +++ b/WebsiteBackend/build.gradle.kts @@ -0,0 +1,61 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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 . + */ + +plugins { + steamwar.kotlin + id("io.ktor.plugin") version "2.3.12" + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.10" + application +} + +application { + mainClass.set("de.steamwar.ApplicationKt") + + val isDevelopment: Boolean = project.ext.has("development") + applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") +} + +ktor { + fatJar { + archiveFileName.set("api.jar") + } +} + +dependencies { + implementation(libs.logback) + implementation(libs.ktor) + implementation(libs.ktorContentNegotiation) + implementation(libs.ktorCors) + implementation(libs.ktorSerialization) + implementation(libs.ktorNetty) + implementation(libs.ktorHost) + implementation(libs.ktorRequestValidation) + implementation(libs.ktorAuth) + implementation(libs.ktorAuthJvm) + implementation(libs.ktorAuthLdap) + implementation(libs.ktorClientCore) + implementation(libs.ktorClientJava) + implementation(libs.ktorClientContentNegotiation) + implementation(libs.ktorClientAuth) + implementation(libs.mysql) + implementation(project(":CommonCore")) + implementation(libs.yamlconfig) + implementation(libs.kotlinxSerializationCbor) + implementation(libs.ktorRateLimit) +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/Application.kt b/WebsiteBackend/src/de/steamwar/Application.kt new file mode 100644 index 00000000..1b653a60 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/Application.kt @@ -0,0 +1,61 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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 + +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 +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import java.io.File + +@Serializable +data class ResponseError(val error: String, val code: String = error) + +@Serializable +data class Config(val giteaToken: String) + +@OptIn(ExperimentalSerializationApi::class) +val config = Json.decodeFromStream(File("config.json").inputStream()) + +fun main() { + SchematicType.Normal.name().length + Thread { + while (true) { + Thread.sleep(1000 * 10) + ResponseUser.clearCache() + } + }.start() + embeddedServer(Netty, port = 1337, host = "127.0.0.1", module = Application::module) + .start(wait = true) +} + +fun Application.module() { + configurePlugins() + configureRoutes() +} diff --git a/WebsiteBackend/src/de/steamwar/bungee/Bungee.kt b/WebsiteBackend/src/de/steamwar/bungee/Bungee.kt new file mode 100644 index 00000000..547aa2cf --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/bungee/Bungee.kt @@ -0,0 +1,56 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.bungee + +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.net.Socket + +object Bungee { + var socket: Socket? = null + var output: ObjectOutputStream? = null + var thread: Thread? = null + + fun run() { + val input = ObjectInputStream(socket!!.getInputStream()) + + while (socket != null && !socket!!.isClosed) { + if (input.available() <= 0) { + Thread.sleep(50) + continue + } + } + + socket = null + thread = null + output = null + } + + inline fun connect(func: (Socket) -> Unit) { + if(socket == null || socket!!.isClosed) { + socket = Socket("localhost", 7546) + output = ObjectOutputStream(socket!!.getOutputStream()) + thread = Thread(Bungee::run) + thread!!.start() + } + + func(socket!!) + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/data/Groups.kt b/WebsiteBackend/src/de/steamwar/data/Groups.kt new file mode 100644 index 00000000..ae895bfc --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/data/Groups.kt @@ -0,0 +1,86 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.data + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +@Serializable +data class GroupsData(val groups: MutableList) + +@Serializable +data class GroupData(val name: String, val fights: MutableList) + +@OptIn(ExperimentalSerializationApi::class) +class Groups { + companion object { + private var groups: GroupsData = if (kGroupsFile.exists()) { + Cbor.decodeFromByteArray(kGroupsFile.readBytes()) + } else { + kGroupsFile.createNewFile() + kGroupsFile.writeBytes(Cbor.encodeToByteArray(GroupsData(mutableListOf()))) + + GroupsData(mutableListOf()) + } + + fun getGroup(name: String): GroupData? { + return groups.groups.find { it.name == name } + } + + fun getGroup(fight: Int): GroupData? { + return groups.groups.find { it.fights.contains(fight) } + } + + fun getOrCreateGroup(name: String): GroupData { + val group = getGroup(name) + if (group != null) { + return group + } + val newGroup = GroupData(name, mutableListOf()) + groups.groups.add(newGroup) + return newGroup + } + + fun resetGroup(fight: Int, save: Boolean = false) { + val oldGroup = getGroup(fight) + oldGroup?.fights?.remove(fight) + if(oldGroup?.fights?.isEmpty() == true) { + groups.groups.remove(oldGroup) + } + if(save) { + kGroupsFile.writeBytes(Cbor.encodeToByteArray(groups)) + } + } + + fun setGroup(fight: Int, group: String) { + resetGroup(fight) + val newGroup = getOrCreateGroup(group) + newGroup.fights.add(fight) + kGroupsFile.writeBytes(Cbor.encodeToByteArray(groups)) + } + + fun getAllGroups(): List { + return groups.groups.map { it.name } + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/data/Paths.kt b/WebsiteBackend/src/de/steamwar/data/Paths.kt new file mode 100644 index 00000000..145f958f --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/data/Paths.kt @@ -0,0 +1,30 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.data + +import java.io.File + +const val kDataFolder: String = "data" + +const val kGroupsName: String = "groups.cbor" +val kGroupsFile: File = File(kDataFolder, kGroupsName) + +const val kRelationsName = "relations.cbor" +val kRelationsFile: File = File(kDataFolder, kRelationsName) \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/data/SkinCache.kt b/WebsiteBackend/src/de/steamwar/data/SkinCache.kt new file mode 100644 index 00000000..6a6ff935 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/data/SkinCache.kt @@ -0,0 +1,110 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.data + +import io.ktor.client.* +import io.ktor.client.engine.java.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.utils.io.jvm.javaio.* +import kotlinx.coroutines.* +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import java.io.File +import java.time.Instant +import java.time.temporal.ChronoUnit + +const val kCacheFolder: String = "skins" +val kCacheFolderFile: File = File(kCacheFolder) +const val kCacheConfigName: String = "cache.cbor" +val kCacheConfigFile: File = File(kCacheFolder, kCacheConfigName) + +@Serializable +data class CacheConfig(val lastUpdate: MutableMap) { + @OptIn(ExperimentalSerializationApi::class) + companion object { + private var config: CacheConfig = if (kCacheConfigFile.exists()) { + kCacheConfigFile.inputStream().use { + Cbor.decodeFromByteArray(it.readBytes()) + } + } else { + kCacheConfigFile.createNewFile() + kCacheConfigFile.outputStream().use { + it.write(Cbor.encodeToByteArray(CacheConfig(mutableMapOf()))) + } + + CacheConfig(mutableMapOf()) + } + + private fun save() { + kCacheConfigFile.outputStream().use { + it.write(Cbor.encodeToByteArray(config)) + } + } + + fun update(uuid: String) { + config.lastUpdate[uuid] = Instant.now().toEpochMilli() + save() + } + + fun isOutdated(uuid: String): Boolean { + return config.lastUpdate[uuid]?.let { + it < Instant.now().minus(1, ChronoUnit.DAYS).toEpochMilli() + } ?: true + } + } +} + +val client = HttpClient(Java) { + install(ContentNegotiation) { + json() + } + defaultRequest { + header("User-Agent", "SteamWar/1.0") + } +} + +suspend fun getCachedSkin(uuid: String): Pair { + val file = File(kCacheFolderFile, "$uuid.webp") + if (file.exists()) { + if (CacheConfig.isOutdated(uuid)) { + val skin = client.get("https://visage.surgeplay.com/bust/150/$uuid") + skin.bodyAsChannel().copyTo(file.outputStream()) + + CacheConfig.update(uuid) + return file to false + } + return file to true + } + + withContext(Dispatchers.IO) { + file.createNewFile() + } + val skin = client.get("https://visage.surgeplay.com/bust/150/$uuid") + skin.bodyAsChannel().copyTo(file.outputStream()) + CacheConfig.update(uuid) + return file to false +} diff --git a/WebsiteBackend/src/de/steamwar/plugins/Auth.kt b/WebsiteBackend/src/de/steamwar/plugins/Auth.kt new file mode 100644 index 00000000..40006aa5 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/plugins/Auth.kt @@ -0,0 +1,104 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.plugins + +import de.steamwar.sql.SWException +import de.steamwar.sql.SteamwarUser +import de.steamwar.sql.Token +import de.steamwar.sql.UserPerm +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.application.hooks.* +import io.ktor.server.auth.* +import io.ktor.server.request.* +import io.ktor.server.response.* + + +data class SWAuthPrincipal(val token: Token, val user: SteamwarUser) : Principal + +class SWAuthConfig { + var permission: UserPerm? = null + var allowedMethods = mutableListOf() + var userCheck: SWAuthPrincipal.(ApplicationRequest) -> Boolean = { true } + var mustAuth: Boolean = false + + fun allowMethod(method: HttpMethod) { + allowedMethods.add(method) + } + + fun allowMethods(methods: List) { + allowedMethods.addAll(methods) + } + + fun userCheck(check: SWAuthPrincipal.(ApplicationRequest) -> Boolean) { + userCheck = check + } +} + +val SWPermissionCheck = createRouteScopedPlugin("SWAuth", ::SWAuthConfig) { + pluginConfig.apply { + on(AuthenticationChecked) { call -> + if (call.request.httpMethod in allowedMethods) { + if(mustAuth) { + val token = call.principal() + + if (token == null) { + call.respond(HttpStatusCode.Unauthorized) + } + } + + return@on + } + + val token = call.principal() + + if (token == null) { + call.respond(HttpStatusCode.Unauthorized) + return@on + } + + if (permission != null && !token.user.hasPerm(permission)) { + call.respond(HttpStatusCode.Forbidden) + return@on + } + + if (!token.userCheck(call.request)) { + call.respond(HttpStatusCode.Forbidden) + return@on + } + } + } +} + +val ErrorLogger = createApplicationPlugin("SWLogger") { + on(CallFailed) { call, cause -> + val msg = """ +{ +URI: ${call.request.uri} +Method: ${call.request.httpMethod.value} +Headers: ${call.request.headers.entries().joinToString("\n ") { "${it.key}: ${it.value}" }} + +Message: ${cause.message} +} + """ + + call.response.headers.append("X-Caught", "1") + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/plugins/Plugins.kt b/WebsiteBackend/src/de/steamwar/plugins/Plugins.kt new file mode 100644 index 00000000..b8780f64 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/plugins/Plugins.kt @@ -0,0 +1,78 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.plugins + +import de.steamwar.sql.Token +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.plugins.ratelimit.* +import kotlinx.serialization.json.Json +import kotlin.time.Duration.Companion.seconds + +fun Application.configurePlugins() { + install(CORS) { + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) + allowMethod(HttpMethod.Put) + allowMethod(HttpMethod.Delete) + allowHeader(HttpHeaders.Authorization) + allowHeader(HttpHeaders.AccessControlAllowOrigin) + allowHeader(HttpHeaders.ContentType) + anyHost() + allowXHttpMethodOverride() + } + install(RateLimit) { + global { + rateLimiter(limit = 60, refillPeriod = 60.seconds) + requestKey { + it.request.headers["X-Forwarded-For"] ?: it.request.local.remoteHost + } + requestWeight { applicationCall, _ -> + if(applicationCall.request.headers["X-Forwarded-For"] != null) { + 0 + } else { + 1 + } + } + } + } + authentication { + bearer("sw-auth") { + realm = "SteamWar API" + authenticate { call -> + val token = Token.getTokenByCode(call.token) + if (token == null) { + null + } else { + SWAuthPrincipal(token, token.owner) + } + } + } + } + install(ContentNegotiation) { + json(Json) + } + install(ErrorLogger) +} diff --git a/WebsiteBackend/src/de/steamwar/plugins/SteamWar.kt b/WebsiteBackend/src/de/steamwar/plugins/SteamWar.kt new file mode 100644 index 00000000..bcac22bc --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/plugins/SteamWar.kt @@ -0,0 +1,27 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.plugins + +import de.steamwar.sql.SteamwarUser +import io.ktor.server.request.* + +fun ApplicationRequest.getUser(key: String = "id"): SteamwarUser? { + return SteamwarUser.get(call.parameters[key]?.toIntOrNull() ?: return null) +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/Auth.kt b/WebsiteBackend/src/de/steamwar/routes/Auth.kt new file mode 100644 index 00000000..d230c4f4 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Auth.kt @@ -0,0 +1,168 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.ResponseError +import de.steamwar.plugins.SWAuthPrincipal +import de.steamwar.plugins.SWPermissionCheck +import de.steamwar.sql.SteamwarUser +import de.steamwar.sql.Token +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.Serializable +import java.time.format.DateTimeFormatter +import java.time.LocalDateTime + +@Serializable +data class AuthLoginRequest(val username: String, val password: String) + +@Serializable +data class AuthTokenResponse(val token: String) + +@Serializable +data class ResponseToken(val id: Int, val name: String, val created: String) { + constructor(token: Token) : this(token.id, token.name, token.created.toLocalDateTime().toString()) +} + +@Serializable +data class CreateTokenRequest(val name: String, val password: String) + +fun Route.configureAuthRoutes() { + route("/auth") { + post("/login") { + if (call.principal() != null) { + call.respond(HttpStatusCode.Forbidden, ResponseError("Already logged in", "already_logged_in")) + return@post + } + + val request = call.receive() + + val user = SteamwarUser.get(request.username) + + if (user == null) { + call.respond(HttpStatusCode.Forbidden, ResponseError("Invalid username or password", "invalid")) + return@post + } + + if (!user.verifyPassword(request.password)) { + call.respond(HttpStatusCode.Forbidden, ResponseError("Invalid username or password", "invalid")) + return@post + } + + val code = Token.createToken("Website: ${DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now())}", user) + call.respond(AuthTokenResponse(code)) + } + route("/tokens") { + install(SWPermissionCheck) { + mustAuth = true + } + + get { + val auth = call.principal() + + if(auth == null) { + call.respond(HttpStatusCode.InternalServerError) + return@get + } + + call.respond(Token.listUser(auth.user).map { ResponseToken(it) }) + } + + post { + val auth = call.principal() + + if(auth == null) { + call.respond(HttpStatusCode.InternalServerError) + return@post + } + + val request = call.receive() + + if(request.name.length > 32) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Name too long", "name_too_long")) + return@post + } + + if(request.name.length < 3) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Name too short", "name_too_short")) + return@post + } + + if(!auth.user.verifyPassword(request.password)) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid password", "invalid_password")) + return@post + } + + val token = Token.createToken(request.name, auth.user) + + call.respond(AuthTokenResponse(token)) + } + + route("/{id}") { + delete { + val auth = call.principal() + + if(auth == null) { + call.respond(HttpStatusCode.InternalServerError) + return@delete + } + + val id = call.parameters["id"]?.toIntOrNull() + + if(id == null) { + call.respond(HttpStatusCode.BadRequest) + return@delete + } + + val token = Token.get(id) + + if(token == null) { + call.respond(HttpStatusCode.NotFound) + return@delete + } + + if(token.owner != auth.user) { + call.respond(HttpStatusCode.Forbidden) + return@delete + } + + token.delete() + call.respond(HttpStatusCode.OK) + } + } + + post("/logout") { + val auth = call.principal() + + if(auth == null) { + call.respond(HttpStatusCode.InternalServerError) + return@post + } + + auth.token.delete() + call.respond(HttpStatusCode.OK) + } + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/Data.kt b/WebsiteBackend/src/de/steamwar/routes/Data.kt new file mode 100644 index 00000000..d05ffc90 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Data.kt @@ -0,0 +1,150 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.ResponseError +import de.steamwar.data.Groups +import de.steamwar.data.getCachedSkin +import de.steamwar.plugins.SWAuthPrincipal +import de.steamwar.plugins.SWPermissionCheck +import de.steamwar.sql.SchematicType +import de.steamwar.sql.SteamwarUser +import de.steamwar.sql.UserPerm +import de.steamwar.sql.loadSchematicTypes +import de.steamwar.util.fetchData +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.Serializable +import org.bspfsystems.yamlconfiguration.file.YamlConfiguration +import java.io.File +import java.net.InetSocketAddress +import java.util.UUID + +@Serializable +data class ResponseSchematicType(val name: String, val db: String) + +@Serializable +data class ResponseUser(val id: Int, val name: String, val uuid: String, val prefix: String, val perms: List) { + constructor(user: SteamwarUser) : this(user.id, user.userName, user.uuid.toString(), user.prefix().chatPrefix, user.perms().map { it.name }) { + synchronized(cache) { + cache[id] = this + } + } + + companion object { + private val cache = mutableMapOf() + + fun get(id: Int): ResponseUser { + synchronized(cache) { + return cache[id] ?: ResponseUser(SteamwarUser.get(id)).also { cache[id] = it } + } + } + + fun clearCache() { + synchronized(cache) { + cache.clear() + } + } + } +} + +fun Route.configureDataRoutes() { + route("/data") { + get { + call.respondText("Hello World!") + } + get("/schematicTypes") { + val types = mutableListOf() + loadSchematicTypes(types, mutableMapOf()) + call.respond(types.filter { !it.check() }.map { ResponseSchematicType(it.name(), it.toDB()) }) + } + get("/gamemodes") { + call.respond( + File("/configs/GameModes/").listFiles()!! + .filter { it.name.endsWith(".yml") && !it.name.endsWith(".kits.yml") } + .map { it.nameWithoutExtension }) + } + get("/gamemodes/{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")) + } + get("/users") { + call.respond(SteamwarUser.getAll().map { ResponseUser(it) }) + } + 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) } } + ) + } + get("/groups") { + call.respond(Groups.getAllGroups()) + } + get("/server") { + 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("/skin/{uuid}") { + val uuid = call.parameters["uuid"] + if (uuid == null || catchException { UUID.fromString(uuid) } == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid UUID")) + return@get + } + + val skin = getCachedSkin(uuid) + call.response.header("X-Cache", if (skin.second) "HIT" else "MISS") + call.response.header("Cache-Control", "public, max-age=604800") + call.respondFile(skin.first) + } + + route("/me") { + install(SWPermissionCheck) + get { + call.respond(ResponseUser(call.principal()!!.user)) + } + } + } +} + +inline fun catchException(yield: () -> T): T? = try { + yield() +} catch (e: Exception) { + null +} diff --git a/WebsiteBackend/src/de/steamwar/routes/EventFights.kt b/WebsiteBackend/src/de/steamwar/routes/EventFights.kt new file mode 100644 index 00000000..8112d87c --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/EventFights.kt @@ -0,0 +1,171 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.ResponseError +import de.steamwar.data.Groups +import de.steamwar.plugins.SWPermissionCheck +import de.steamwar.sql.EventFight +import de.steamwar.sql.SteamwarUser +import de.steamwar.sql.Team +import de.steamwar.sql.UserPerm +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.Serializable +import java.sql.Timestamp +import java.time.Instant + +@Serializable +data class ResponseEventFight( + val id: Int, + val spielmodus: String, + val map: String, + val blueTeam: ResponseTeam, + val redTeam: ResponseTeam, + val start: Long, + val ergebnis: Int, + val spectatePort: Int?, + val group: String? +) { + constructor(eventFight: EventFight) : this( + eventFight.fightID, + eventFight.spielmodus, + eventFight.map, + ResponseTeam(Team.get(eventFight.teamBlue)), + ResponseTeam(Team.get(eventFight.teamRed)), + eventFight.startTime.time, + eventFight.ergebnis, + eventFight.spectatePort, + Groups.getGroup(eventFight.fightID)?.name + ) +} + +@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) +} + +@Serializable +data class UpdateEventFight( + val blueTeam: Int? = null, + val redTeam: Int? = null, + val start: Long? = null, + val spielmodus: String? = null, + val map: String? = null, + val group: String? = null, + val spectatePort: Int? = null +) + +@Serializable +data class CreateEventFight( + val event: Int, + val spielmodus: String, + val map: String, + val blueTeam: Int, + val redTeam: Int, + val start: Long, + val spectatePort: Int? = null, + val group: String? = null +) + +fun Route.configureEventFightRoutes() { + route("/fights") { + install(SWPermissionCheck) { + allowMethod(HttpMethod.Get) + permission = UserPerm.MODERATION + } + post { + val fight = call.receiveNullable() + if (fight == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid body")) + return@post + } + val eventFight = EventFight.create( + fight.event, + Timestamp.from(Instant.ofEpochMilli(fight.start)), + fight.spielmodus, + fight.map, + fight.blueTeam, + fight.redTeam, + fight.spectatePort + ) + if (fight.group != null) { + if (fight.group != "null") { + Groups.setGroup(eventFight.fightID, fight.group) + } + } + call.respond(HttpStatusCode.Created, ResponseEventFight(eventFight)) + } + route("/{fight}") { + put { + val fightId = call.parameters["fight"]?.toIntOrNull() + if (fightId == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid fight ID")) + return@put + } + val fight = EventFight.get(fightId) + if (fight == null) { + call.respond(HttpStatusCode.NotFound, ResponseError("Fight not found")) + return@put + } + val updateFight = call.receiveNullable() + if (updateFight == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid body")) + return@put + } + + val teamBlue = updateFight.blueTeam ?: fight.teamBlue + val teamRed = updateFight.redTeam ?: fight.teamRed + val start = updateFight.start?.let { Timestamp.from(Instant.ofEpochMilli(it)) } ?: fight.startTime + val spielmodus = updateFight.spielmodus ?: fight.spielmodus + val map = updateFight.map ?: fight.map + val spectatePort = updateFight.spectatePort ?: fight.spectatePort + + if (updateFight.group != null) { + if (updateFight.group == "null") { + Groups.resetGroup(fightId, true) + } else { + Groups.setGroup(fightId, updateFight.group) + } + } + fight.update(start, spielmodus, map, teamBlue, teamRed, spectatePort) + call.respond(HttpStatusCode.OK, ResponseEventFight(fight)) + } + delete { + val fightId = call.parameters["fight"]?.toIntOrNull() + if (fightId == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid fight ID")) + return@delete + } + val fight = EventFight.get(fightId) + if (fight == null) { + call.respond(HttpStatusCode.NotFound, ResponseError("Fight not found")) + return@delete + } + fight.delete() + call.respond(HttpStatusCode.OK) + } + + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/Events.kt b/WebsiteBackend/src/de/steamwar/routes/Events.kt new file mode 100644 index 00000000..987479c2 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Events.kt @@ -0,0 +1,252 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.ResponseError +import de.steamwar.data.Groups +import de.steamwar.plugins.SWPermissionCheck +import de.steamwar.sql.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.Serializable +import java.lang.StringBuilder +import java.sql.Timestamp +import java.time.Instant + +@Serializable +data class ShortEvent(val id: Int, val name: String, val start: Long, val end: Long) { + constructor(event: Event) : this(event.eventID, event.eventName, event.start.time, event.end.time) +} + +@Serializable +data class ResponseEvent( + val id: Int, + val name: String, + val deadline: Long, + val start: Long, + val end: Long, + val maxTeamMembers: Int, + val schemType: String?, + val publicSchemsOnly: Boolean, + val referees: List, +) { + constructor(event: Event) : this( + event.eventID, + event.eventName, + event.deadline.time, + event.start.time, + event.end.time, + event.maximumTeamMembers, + event.schematicType?.toDB(), + event.publicSchemsOnly(), + Referee.get(event.eventID).map { ResponseUser(SteamwarUser.get(it)) } + ) +} + +@Serializable +data class ExtendedResponseEvent( + val event: ResponseEvent, + val teams: List, + val fights: List +) + +@Serializable +data class CreateEvent(val name: String, val start: Long, val end: Long) + +@Serializable +data class UpdateEvent( + val name: String? = null, + val deadline: Long? = null, + val start: Long? = null, + val end: Long? = null, + val maxTeamMembers: Int? = null, + val schemType: String? = null, + val publicSchemsOnly: Boolean? = null, + val addReferee: Set? = null, + val removeReferee: Set? = null, +) + +fun Route.configureEventsRoute() { + route("/events") { + install(SWPermissionCheck) { + allowMethod(HttpMethod.Get) + permission = UserPerm.MODERATION + } + get { + call.respond(Event.getAll().map { ShortEvent(it) }) + } + post { + val createEvent = call.receiveNullable() + if (createEvent == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid body")) + return@post + } + val event = Event.create( + createEvent.name, + Timestamp.from(Instant.ofEpochMilli(createEvent.start)), + Timestamp.from(Instant.ofEpochMilli(createEvent.end)) + ) + call.respond(HttpStatusCode.Created, ResponseEvent(event)) + } + route("/{id}") { + get { + val id = call.parameters["id"]?.toIntOrNull() + if (id == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid ID")) + return@get + } + val event = Event.get(id) + if (event == null) { + call.respond(HttpStatusCode.NotFound, ResponseError("Event not found")) + return@get + } + call.respond( + ExtendedResponseEvent( + ResponseEvent(event), + TeamTeilnahme.getTeams(event.eventID).map { ResponseTeam(it) }, + EventFight.getEvent(event.eventID).map { ResponseEventFight(it) }) + ) + } + get("/teams") { + val id = call.parameters["id"]?.toIntOrNull() + if (id == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid ID")) + return@get + } + val event = Event.get(id) + if (event == null) { + call.respond(HttpStatusCode.NotFound, ResponseError("Event not found")) + return@get + } + call.respond(TeamTeilnahme.getTeams(event.eventID).map { ResponseTeam(it) }) + } + get("/fights") { + val id = call.parameters["id"]?.toIntOrNull() + if (id == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid ID")) + return@get + } + val event = Event.get(id) + if (event == null) { + call.respond(HttpStatusCode.NotFound, ResponseError("Event not found")) + return@get + } + call.respond(EventFight.getEvent(event.eventID).map { ResponseEventFight(it) }) + } + get("/csv") { + val id = call.parameters["id"]?.toIntOrNull() + if (id == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid ID")) + return@get + } + val event = Event.get(id) + if (event == null) { + call.respond(HttpStatusCode.NotFound, ResponseError("Event not found")) + return@get + } + + val fights = EventFight.getEvent(event.eventID) + val csv = StringBuilder(); + csv.append(arrayOf("Start", "BlueTeam", "RedTeam", "WinnerTeam", "Group").joinToString(",")) + fights.forEach { + csv.appendLine() + val blue = Team.get(it.teamBlue) + val red = Team.get(it.teamRed) + val winner = when(it.ergebnis) { + 1 -> blue.teamName + 2 -> red.teamName + 3 -> "Tie" + else -> "Unknown" + } + csv.append( + arrayOf( + it.startTime.toString(), + Team.get(it.teamBlue).teamName, + Team.get(it.teamRed).teamName, + winner, + Groups.getGroup(it.fightID)?.name ?: "Ungrouped" + ).joinToString(",") + ) + } + call.response.header("Content-Disposition", "attachment; filename=\"${event.eventName}.csv\"") + call.response.header("Content-Type", "text/csv") + call.response.header("Content-Transfer-Encoding", "binary") + call.response.header("Pragma", "no-cache") + call.respondText(csv.toString()) + } + put { + val id = call.parameters["id"]?.toIntOrNull() + if (id == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid ID")) + return@put + } + val event = Event.get(id) + if (event == null) { + call.respond(HttpStatusCode.NotFound, ResponseError("Event not found")) + return@put + } + val updateEvent = call.receiveNullable() + if (updateEvent == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid body")) + return@put + } + val eventName = updateEvent.name ?: event.eventName + val deadline = updateEvent.deadline?.let { Timestamp.from(Instant.ofEpochMilli(it)) } ?: event.deadline + val start = updateEvent.start?.let { Timestamp.from(Instant.ofEpochMilli(it)) } ?: event.start + val end = updateEvent.end?.let { Timestamp.from(Instant.ofEpochMilli(it)) } ?: event.end + val maxTeamMembers = updateEvent.maxTeamMembers ?: event.maximumTeamMembers + + val schemType = if (updateEvent.schemType == "null") null else updateEvent.schemType?.let { SchematicType.fromDB(it) } ?: event.schematicType + val publicSchemsOnly = updateEvent.publicSchemsOnly ?: event.publicSchemsOnly() + + if (updateEvent.addReferee != null) { + updateEvent.addReferee.forEach { + Referee.add(event.eventID, it) + } + } + + if (updateEvent.removeReferee != null) { + updateEvent.removeReferee.forEach { + Referee.remove(event.eventID, it) + } + } + event.update(eventName, deadline, start, end, schemType, maxTeamMembers, publicSchemsOnly) + call.respond(ResponseEvent(event)) + } + delete { + val id = call.parameters["id"]?.toIntOrNull() + if (id == null) { + call.respond(HttpStatusCode.BadRequest, ResponseError("Invalid ID")) + return@delete + } + val event = Event.get(id) + if (event == null) { + call.respond(HttpStatusCode.NotFound, ResponseError("Event not found")) + return@delete + } + event.delete() + call.respond(HttpStatusCode.NoContent) + } + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/Page.kt b/WebsiteBackend/src/de/steamwar/routes/Page.kt new file mode 100644 index 00000000..9b28ddd5 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Page.kt @@ -0,0 +1,251 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.config +import de.steamwar.plugins.SWAuthPrincipal +import de.steamwar.plugins.SWPermissionCheck +import de.steamwar.sql.UserPerm +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.java.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.util.reflect.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.* +import java.util.Base64 + +val pathPageIdMap = mutableMapOf() +var pageId = 1 + +@Serializable +data class Identity(val name: String, val email: String) + +@Serializable +data class PageResponseList( + val path: String, + val name: String, + val sha: String, + val downloadUrl: String, + val id: Int +) { + constructor(res: JsonObject, id: Int) : this( + res["path"]?.jsonPrimitive?.content!!, + res["name"]?.jsonPrimitive?.content!!, + res["sha"]?.jsonPrimitive?.content!!, + res["download_url"]?.jsonPrimitive?.content!!, + id + ) +} + +@Serializable +data class PageResponse( + val path: String, + val name: String, + val sha: String, + val downloadUrl: String, + val content: String, + val size: Int, + val id: Int, +) { + constructor(res: JsonObject, id: Int) : this( + res["path"]?.jsonPrimitive?.content!!, + res["name"]?.jsonPrimitive?.content!!, + res["sha"]?.jsonPrimitive?.content!!, + res["download_url"]?.jsonPrimitive?.content!!, + res["content"]?.jsonPrimitive?.content!!, + res["size"]?.jsonPrimitive?.int!!, + id + ) +} + +@Serializable +data class CreatePageRequest(val path: String, val slug: String?, val title: String?) + +@Serializable +data class CreateBranchRequest(val branch: String) + +@Serializable +data class UpdatePageRequest(val content: String, val sha: String, val message: String) + +@Serializable +data class MergeBranchRequest(val branch: String, val message: String) + +@Serializable +data class DeletePageRequest(val sha: String, val message: String) + +fun Route.configurePage() { + val client = HttpClient(Java) { + install(ContentNegotiation) { + json() + } + defaultRequest { + url("https://steamwar.de/devlabs/api/v1/") + header("Authorization", "token " + config.giteaToken) + } + } + + route("page") { + install(SWPermissionCheck) { + permission = UserPerm.MODERATION + } + get { + val branch = call.request.queryParameters["branch"] ?: "master" + val filesToCheck = mutableListOf("src/content") + val files = mutableListOf() + + while (filesToCheck.isNotEmpty()) { + val path = filesToCheck.removeAt(0) + val res = client.get("repos/SteamWar/Website/contents/$path?ref=$branch") + val fileJson = Json.parseToJsonElement(res.bodyAsText()) + + if (fileJson is JsonArray) { + fileJson.forEach { + val obj = it.jsonObject + if (obj["type"]?.jsonPrimitive?.content == "dir") { + filesToCheck.add(obj["path"]?.jsonPrimitive?.content!!) + } else if (obj["type"]?.jsonPrimitive?.content == "file" && (obj["name"]?.jsonPrimitive?.content?.endsWith(".md") == true || obj["name"]?.jsonPrimitive?.content?.endsWith(".json") == true)) { + files.add(PageResponseList(obj, pathPageIdMap.computeIfAbsent(obj["path"]?.jsonPrimitive?.content!!) { pageId++ })) + } + } + } else { + files.add(PageResponseList(fileJson.jsonObject, pathPageIdMap.computeIfAbsent(fileJson.jsonObject["path"]?.jsonPrimitive?.content!!) { pageId++ })) + } + } + + call.respond(files) + } + get("branch") { + val res = client.get("repos/SteamWar/Website/branches") + call.respond(res.status, Json.parseToJsonElement(res.bodyAsText()).jsonArray.map { it.jsonObject["name"]?.jsonPrimitive?.content!! }) + } + post("branch") { + @Serializable + data class CreateGiteaBranchRequest(val new_branch_name: String, val old_branch_name: String) + + val branch = call.receive().branch + val res = client.post("repos/SteamWar/Website/branches") { + contentType(ContentType.Application.Json) + setBody(CreateGiteaBranchRequest(branch, "master")) + } + + + @Serializable + data class CreateGiteaMergeRequest(val base: String, val head: String, val title: String) + + client.post("repos/SteamWar/Website/pulls") { + contentType(ContentType.Application.Json) + setBody(CreateGiteaMergeRequest("master", branch, "Merge branch $branch")) + } + + call.respond(res.status) + } + delete("branch") { + val branch = call.receive().branch + val res = client.delete("repos/SteamWar/Website/branches/$branch") + call.respond(res.status) + } + post { + @Serializable + data class CreateGiteaPageRequest(val message: String, val content: String, val branch: String, val author: Identity) + + val req = call.receive() + if(req.path.startsWith("src/content/")) { + call.respond(HttpStatusCode.BadRequest, "Invalid path") + return@post + } + val res = client.post("repos/SteamWar/Website/contents/src/content/${req.path}") { + contentType(ContentType.Application.Json) + setBody(CreateGiteaPageRequest( + "Create page ${req.path}", + Base64.getEncoder().encodeToString(""" + --- + title: ${req.title ?: "[Enter Title]"} + description: [Enter Description] + slug: ${req.slug ?: "[Enter Slug]"} + --- + + # ${req.path} + """.trimIndent().toByteArray()), + call.request.queryParameters["branch"] ?: "master", + Identity(call.principal()!!.user.userName, "admin-tool@steamwar.de" + ))) + } + call.respond(res.status) + } + get("{id}") { + val id = call.parameters["id"]?.toIntOrNull() ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid id") + val path = pathPageIdMap.entries.find { it.value == id }?.key ?: return@get call.respond(HttpStatusCode.NotFound, "Page not found") + + val branch = call.request.queryParameters["branch"] ?: "master" + val res = client.get("repos/SteamWar/Website/contents/$path?ref=$branch") + val fileJson = Json.parseToJsonElement(res.bodyAsText()) + if (fileJson is JsonArray) { + return@get call.respond(HttpStatusCode.BadRequest, "Invalid id") + } + + val file = PageResponse(fileJson.jsonObject, id) + call.respond(file) + } + + delete("{id}") { + val data = call.receive() + + val path = pathPageIdMap.entries.find { it.value == call.parameters["id"]?.toIntOrNull() }?.key ?: return@delete call.respond(HttpStatusCode.NotFound, "Page not found") + val branch = call.request.queryParameters["branch"] ?: "master" + + @Serializable + data class DeleteGiteaPageRequest(val sha: String, val message: String, val branch: String, val author: Identity) + + val res = client.delete("repos/SteamWar/Website/contents/$path") { + contentType(ContentType.Application.Json) + setBody(DeleteGiteaPageRequest(data.sha, data.message, branch, Identity(call.principal()!!.user.userName, "admin-tool@steamwar.de"))) + } + + call.respond(res.status) + } + + put("{id}") { + @Serializable + data class UpdateGiteaPageRequest(val content: String, val sha: String, val message: String, val branch: String, val author: Identity) + + val data = call.receive() + val path = pathPageIdMap.entries.find { it.value == call.parameters["id"]?.toIntOrNull() }?.key ?: return@put call.respond(HttpStatusCode.NotFound, "Page not found") + + val res = client.put("repos/SteamWar/Website/contents/$path") { + contentType(ContentType.Application.Json) + setBody(UpdateGiteaPageRequest(data.content, data.sha, data.message, (call.request.queryParameters["branch"] ?: "master"), Identity(call.principal()!!.user.userName, "admin-tool@steamwar.de"))) + } + + call.respond(res.status) + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/Routes.kt b/WebsiteBackend/src/de/steamwar/routes/Routes.kt new file mode 100644 index 00000000..85471d14 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Routes.kt @@ -0,0 +1,40 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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 io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.routing.* + +fun Application.configureRoutes() { + routing { + authenticate("sw-auth", optional = true) { + configureEventsRoute() + configureDataRoutes() + configureEventFightRoutes() + configureUserPerms() + configureStats() + configurePage() + configureSchematic() + configureAuthRoutes() + configureUser() + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/Schematic.kt b/WebsiteBackend/src/de/steamwar/routes/Schematic.kt new file mode 100644 index 00000000..ef2900f5 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Schematic.kt @@ -0,0 +1,229 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.SWAuthPrincipal +import de.steamwar.plugins.SWPermissionCheck +import de.steamwar.sql.NodeData +import de.steamwar.sql.NodeDownload +import de.steamwar.sql.NodeMember +import de.steamwar.sql.SWException +import de.steamwar.sql.SchematicNode +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.Serializable +import java.security.MessageDigest +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* + +@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.id, 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.id).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.id }) +} + +@Serializable +data class ResponseBreadcrumb(val name: String, val id: Int) + +fun generateCode(): String { + val md = MessageDigest.getInstance("SHA-256") + val random = ByteArray(64).map { (0..255).random().toByte() }.toByteArray() + val code = md.digest(random) + + return code.joinToString("") { "%02x".format(it) } +} + +@Serializable +data class SchematicCode(val id: Int, val code: String, val expires: Long) + +@Serializable +data class UploadSchematic(val name: String, val content: String) + +fun Route.configureSchematic() { + route("/download/{code}") { + get { + val code = call.parameters["code"] ?: run { + call.respond(HttpStatusCode.BadRequest) + return@get + } + + val dl = NodeDownload.get(code) ?: run { + call.respond(HttpStatusCode.NotFound) + return@get + } + + dl.delete() + + if(dl.timestamp.toInstant().plus(Duration.of(5, ChronoUnit.MINUTES)).isBefore(Instant.now())) { + call.respond(HttpStatusCode.Gone) + return@get + } + + val node = SchematicNode.getSchematicNode(dl.nodeId) ?: run { + call.respond(HttpStatusCode.NotFound) + 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.get(node) ?: run { + call.respond(HttpStatusCode.InternalServerError) + return@get + } + + call.response.header("Content-Disposition", "attachment; filename=\"${node.name}.${if (data.nodeFormat) "schem" else "schematic"}\"") + call.respondBytes(data.schemData().readAllBytes(), contentType = ContentType.Application.OctetStream, status = HttpStatusCode.OK) + } + get("/info") { + val code = call.parameters["code"] ?: run { + call.respond(HttpStatusCode.BadRequest) + return@get + } + + val dl = NodeDownload.get(code) ?: run { + call.respond(HttpStatusCode.NotFound) + return@get + } + + val node = SchematicNode.getSchematicNode(dl.nodeId) ?: run { + call.respond(HttpStatusCode.NotFound) + return@get + } + + call.respond(ResponseSchematic(node)) + } + } + route("/schem") { + install(SWPermissionCheck) + get { + val user = call.principal()!!.user + call.respond(ResponseSchematicList(SchematicNode.list(user, null).filter { it.name != "//copy" }.sortedWith { o1, o2 -> + if (o1.isDir || o2.isDir) { + o2.isDir.compareTo(o1.isDir) + } else { + o1.name.compareTo(o2.name) + } + }.map { ResponseSchematic(it) }, listOf())) + } + + post { + val file = call.receive() + val schemName = file.name.substringBeforeLast(".") + val schemType = file.name.substringAfterLast(".") + + if (schemType != "schem" && schemType != "schematic") { + call.respond(HttpStatusCode.BadRequest) + return@post + } + + val user = call.principal()!!.user + + val content = Base64.getDecoder().decode(file.content) + var node = SchematicNode.getSchematicNode(user.id, schemName, 0) + if (node == null) { + node = SchematicNode.createSchematic(user.id, schemName, 0) + } + + val data = NodeData(node.id, false) + data.saveFromStream(content.inputStream(), schemType == "schem") + + call.respond(ResponseSchematic(node)) + } + + route("/{id}") { + get { + val user = call.principal()!!.user + val parentId = call.parameters["id"]?.toIntOrNull() + if(parentId == null) { + call.respond(HttpStatusCode.BadRequest) + return@get + } + + val parent = SchematicNode.getSchematicNode(parentId) + + if(parent == null) { + call.respond(HttpStatusCode.NotFound) + return@get + } + + if(!parent.accessibleByUser(user)) { + call.respond(HttpStatusCode.Forbidden) + return@get + } + + call.respond(ResponseSchematicLong(parent, parent.generateBreadcrumbs(user))) + } + + get("/list") { + val user = call.principal()!!.user + val parentId = call.parameters["id"]?.toIntOrNull() + if(parentId == null) { + call.respond(HttpStatusCode.BadRequest) + return@get + } + + val parent = SchematicNode.getSchematicNode(parentId) + + if(parent == null) { + call.respond(HttpStatusCode.NotFound) + return@get + } + + if(!parent.isDir) { + call.respond(HttpStatusCode.BadRequest) + return@get + } + + if(!parent.accessibleByUser(user)) { + call.respond(HttpStatusCode.Forbidden) + return@get + } + + call.respond(ResponseSchematicList(SchematicNode.list(user, parent.id).filter { it.name != "//copy" }.sortedWith { o1, o2 -> + if (o1.isDir || o2.isDir) { + o2.isDir.compareTo(o1.isDir) + } else { + o1.name.compareTo(o2.name) + } + }.map { ResponseSchematic(it) }, parent.generateBreadcrumbsMap(user).map { ResponseBreadcrumb(it.key, it.value) })) + } + } + } +} diff --git a/WebsiteBackend/src/de/steamwar/routes/Stats.kt b/WebsiteBackend/src/de/steamwar/routes/Stats.kt new file mode 100644 index 00000000..de3ee2e3 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Stats.kt @@ -0,0 +1,87 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.SWAuthPrincipal +import de.steamwar.plugins.SWPermissionCheck +import de.steamwar.plugins.getUser +import de.steamwar.sql.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.Serializable + +@Serializable +data class UserStats(val eventFightParticipation: Int, val eventParticipation: Int, val acceptedSchematics: Int, val fights: Int, val playtime: Double) { + constructor(user: SteamwarUser): this( + getEventFightParticipation(user) ?: 0, + getEventParticipation(user) ?: 0, + getAcceptedSchematics(user) ?: 0, + getFightCount(user) ?: 0, + user.onlinetime / 3600.0 + ) +} + +fun Route.configureStats() { + route("/stats") { + get("/ranked/{gamemode}") { + val gamemode = call.parameters["gamemode"] ?: return@get call.respond(HttpStatusCode.NotFound) + + @Serializable + data class RankedUser(val name: String, val elo: Int) + + call.respond(getRankedList(gamemode).map { RankedUser(it.first, it.second) }) + } + get("/fights") { + val list = getFightList() + + @Serializable + data class Fight(val date: String, val gamemode: String, val count: Int) + + call.respond(list.map { Fight(it.first, it.second, it.third) }) + } + route("/user/{id}") { + install(SWPermissionCheck) { + userCheck { + val user = it.call.request.getUser() + val auth = it.call.principal() + + if (user == null || auth == null) { + return@userCheck false + } + + return@userCheck user.id == auth.user.id || auth.user.hasPerm(UserPerm.MODERATION) + } + } + get { + val user = call.request.getUser() + + if (user == null) { + call.respond(HttpStatusCode.NotFound, "User not found") + return@get + } + + call.respond(UserStats(user)) + } + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/Teams.kt b/WebsiteBackend/src/de/steamwar/routes/Teams.kt new file mode 100644 index 00000000..b6c5f436 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Teams.kt @@ -0,0 +1,33 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.sql.Team +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.configureTeamRoutes() { + route("/team") { + get { + call.respond(Team.getAll().map { ResponseTeam(it) }) + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/User.kt b/WebsiteBackend/src/de/steamwar/routes/User.kt new file mode 100644 index 00000000..9571a90e --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/User.kt @@ -0,0 +1,54 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.plugins.getUser +import de.steamwar.sql.UserPerm +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.configureUser() { + route("/user") { + route("/{id}") { + route("/admin") { + install(SWPermissionCheck) { + permission = UserPerm.ADMINISTRATION + } + + put("/password") { + val user = call.request.getUser() ?: return@put call.respond(HttpStatusCode.NotFound) + val password = call.receiveText() + + if (password.isEmpty()) { + call.respond(HttpStatusCode.BadRequest, "Password too short") + return@put + } + + user.setPassword(password) + call.respond(HttpStatusCode.OK) + } + } + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/routes/UserPerms.kt b/WebsiteBackend/src/de/steamwar/routes/UserPerms.kt new file mode 100644 index 00000000..dd6244e2 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/UserPerms.kt @@ -0,0 +1,154 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.plugins.getUser +import de.steamwar.sql.SteamwarUser +import de.steamwar.sql.UserPerm +import de.steamwar.sql.UserPerm.Prefix +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.Serializable + +@Serializable +data class RespondPrefix(val name: String, val colorCode: String, val chatPrefix: String) + +@Serializable +data class RespondUserPerms(val prefixes: Map, val perms: List) + +@Serializable +data class RespondUserPermsPrefix(val prefix: RespondPrefix, val perms: List) + +fun Route.configureUserPerms() { + route("/perms") { + install(SWPermissionCheck) { + allowMethod(HttpMethod.Get) + permission = UserPerm.MODERATION + } + get { + val perms = mutableListOf() + val prefixes = mutableMapOf() + UserPerm.entries.forEach { + if (it.name.startsWith("PREFIX_")) { + val prefix = UserPerm.prefixes[it]!! + prefixes[it.name] = RespondPrefix(it.name, prefix.colorCode, prefix.chatPrefix) + } else { + perms.add(it.name) + } + } + + call.respond(RespondUserPerms(prefixes, perms)) + } + route("/user/{id}") { + install(SWPermissionCheck) { + allowMethod(HttpMethod.Get) + permission = UserPerm.MODERATION + mustAuth = true + } + get { + val user = call.request.getUser() + if (user == null) { + call.respond(HttpStatusCode.BadRequest) + return@get + } + val perms = mutableListOf() + var prefix = UserPerm.PREFIX_NONE + user.perms().forEach { + if (it.name.startsWith("PREFIX_")) { + prefix = it + } else { + perms.add(it.name) + } + } + + val prefixs = UserPerm.prefixes[prefix]!! + + call.respond(RespondUserPermsPrefix(RespondPrefix(prefix.name, prefixs.colorCode, prefixs.chatPrefix), perms)) + } + put("/prefix/{prefix}") { + val user = call.request.getUser() + if (user == null) { + call.respond(HttpStatusCode.BadRequest) + return@put + } + + val prefix = call.parameters["prefix"] + if (prefix == null || UserPerm.values().find { it.name == prefix } == null) { + call.respond(HttpStatusCode.BadRequest) + return@put + } + user.perms().filter { it.name.startsWith("PREFIX_") }.forEach { + UserPerm.removePerm(user, it) + } + + UserPerm.addPerm(user, UserPerm.values().find { it.name == prefix }!!) + call.respond(HttpStatusCode.Accepted) + } + put("/{perm}") { + val user = call.request.getUser() + if (user == null) { + call.respond(HttpStatusCode.BadRequest) + return@put + } + + + val perm = call.parameters["perm"] + val permission = UserPerm.values().find { it.name == perm } + if (perm == null || perm.startsWith("PREFIX_") || permission == null) { + call.respond(HttpStatusCode.BadRequest) + return@put + } + + if (!user.hasPerm(permission)) { + UserPerm.addPerm(user, permission) + call.respond(HttpStatusCode.Accepted) + return@put + } + call.respond(HttpStatusCode.NoContent) + } + delete("/{perm}") { + val user = call.request.getUser() + if (user == null) { + call.respond(HttpStatusCode.BadRequest) + return@delete + } + + val perm = call.parameters["perm"] + val permission = UserPerm.values().find { it.name == perm } + if (perm == null || perm.startsWith("PREFIX_") || permission == null) { + call.respond(HttpStatusCode.BadRequest) + return@delete + } + + + + if (user.hasPerm(permission)) { + UserPerm.removePerm(user, permission) + call.respond(HttpStatusCode.Accepted) + return@delete + } + call.respond(HttpStatusCode.NoContent) + } + } + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/sql/SQLConfigImpl.kt b/WebsiteBackend/src/de/steamwar/sql/SQLConfigImpl.kt new file mode 100644 index 00000000..551ed07e --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/sql/SQLConfigImpl.kt @@ -0,0 +1,29 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.sql + +import de.steamwar.sql.internal.SQLConfig +import java.util.logging.Logger + +class SQLConfigImpl: SQLConfig { + override fun getLogger(): Logger = Logger.getGlobal() + + override fun maxConnections(): Int = 1 +} diff --git a/WebsiteBackend/src/de/steamwar/sql/SQLWrapperImpl.kt b/WebsiteBackend/src/de/steamwar/sql/SQLWrapperImpl.kt new file mode 100644 index 00000000..4f2c90e3 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/sql/SQLWrapperImpl.kt @@ -0,0 +1,68 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.sql + +import org.bspfsystems.yamlconfiguration.file.YamlConfiguration +import java.io.File +import java.util.* +import java.util.stream.Collectors + +fun loadSchematicTypes(tmpTypes: MutableList?, tmpFromDB: MutableMap?) { + val folder = File("/configs/GameModes") + if (folder.exists()) { + for (configFile in Arrays.stream(folder.listFiles { _, name -> name.endsWith(".yml") && !name.endsWith(".kits.yml") }) + .sorted().collect(Collectors.toList())) { + val config: YamlConfiguration = YamlConfiguration.loadConfiguration(configFile) + if (!config.isConfigurationSection("Schematic")) continue + val type: String = config.getString("Schematic.Type")!! + val shortcut = config.getString("Schematic.Shortcut") + if (shortcut == null) { + println("No shortcut for $type") + continue + } + if (tmpFromDB!!.containsKey(type.lowercase(Locale.getDefault()))) continue + var checktype: SchematicType? = null + val material: String = config.getString("Schematic.Material", "STONE_BUTTON")!! + if (!config.getStringList("CheckQuestions").isEmpty()) { + checktype = SchematicType("C$type", "C$shortcut", SchematicType.Type.CHECK_TYPE, null, material, false) + tmpTypes!!.add(checktype) + tmpFromDB[checktype.toDB()] = checktype + } + val current = SchematicType( + type, + shortcut, + if (config.isConfigurationSection("Server")) SchematicType.Type.FIGHT_TYPE else SchematicType.Type.NORMAL, + checktype, + material, + false + ) + tmpTypes!!.add(current) + tmpFromDB[type.lowercase(Locale.getDefault())] = current + } + } +} + +class SQLWrapperImpl: SQLWrapper { + override fun loadSchemTypes(tmpTypes: MutableList?, tmpFromDB: MutableMap?) = loadSchematicTypes(tmpTypes, tmpFromDB) + + override fun additionalExceptionMetadata(builder: StringBuilder) { + builder.append("\n\nWebsiteApi") + } +} \ No newline at end of file diff --git a/WebsiteBackend/src/de/steamwar/sql/Stats.kt b/WebsiteBackend/src/de/steamwar/sql/Stats.kt new file mode 100644 index 00000000..d8beb867 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/sql/Stats.kt @@ -0,0 +1,67 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.sql + +import de.steamwar.sql.internal.Statement +import de.steamwar.sql.internal.Statement.ResultSetUser + +private val getNum: ResultSetUser = ResultSetUser { res -> + if (res.next()) { + res.getInt("num") + } else { + null + } +} + +private val eventFightParticipation = Statement("SELECT FightPlayer.UserID, COUNT(UserID) as num FROM FightPlayer INNER JOIN Fight on FightPlayer.FightID = Fight.FightID WHERE Fight.Server LIKE '%vs%' AND FightPlayer.UserID = ? GROUP BY FightPlayer.UserID") + +fun getEventFightParticipation(user: SteamwarUser): Int? = eventFightParticipation.select(getNum, user.id) + +private val eventParticipation = Statement("SELECT FightPlayer.UserID, COUNT(DISTINCT EventID) as num FROM FightPlayer INNER JOIN core.Fight F on FightPlayer.FightID = F.FightID INNER JOIN core.EventFight EF on F.FightID = EF.Fight WHERE F.FightID = FightPlayer.FightID AND FightPlayer.FightID = EF.Fight AND F.Server LIKE '%vs%' AND FightPlayer.UserID = ? GROUP BY FightPlayer.UserID") + +fun getEventParticipation(user: SteamwarUser): Int? = eventParticipation.select(getNum, user.id) + +private val acceptedSchematics = Statement("SELECT NodeOwner, COUNT(DISTINCT NodeId) AS num FROM SchematicNode WHERE NodeType != 'normal' AND NodeType IS NOT NULL AND NodeType NOT LIKE 'c%' AND NodeOwner = ?") + +fun getAcceptedSchematics(user: SteamwarUser): Int? = acceptedSchematics.select(getNum, user.id) + +private val fightCount = Statement("SELECT COUNT(*) AS num FROM FightPlayer WHERE UserID = ?") + +fun getFightCount(user: SteamwarUser): Int? = fightCount.select(getNum, user.id) + +private val rankedList = Statement("SELECT UserName, Elo FROM UserData, UserElo WHERE UserID = id AND GameMode = ? AND Season = ? ORDER BY Elo DESC") + +fun getRankedList(gamemode: String): List> = rankedList.select({ res -> + val list = mutableListOf>() + while (res.next()) { + list.add(res.getString("UserName") to res.getInt("Elo")) + } + list +}, gamemode, Season.getSeason()) + +private val fightList = Statement("SELECT DATE(StartTime) AS Datum, GameMode AS Modus, COUNT(*) AS Anzahl FROM Fight WHERE DATE(StartTime) >= DATE(NOW()) - INTERVAL 1 WEEK GROUP BY Datum, GameMode ORDER BY Datum ASC") + +fun getFightList(): List> = fightList.select({ res -> + val list = mutableListOf>() + while (res.next()) { + list.add(Triple(res.getString("Datum"), res.getString("Modus"), res.getInt("Anzahl"))) + } + list +}) diff --git a/WebsiteBackend/src/de/steamwar/util/ServerPing.kt b/WebsiteBackend/src/de/steamwar/util/ServerPing.kt new file mode 100644 index 00000000..fb2b9a6d --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/util/ServerPing.kt @@ -0,0 +1,137 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2024 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.util + +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import java.io.* +import java.net.InetSocketAddress +import java.net.Socket + +/** + * + * @author zh32 + */ +private fun readVarInt(`in`: DataInputStream): Int { + var i = 0 + var j = 0 + while (true) { + val k = `in`.readByte().toInt() + i = i or (k and 0x7F shl j++ * 7) + if (j > 5) throw RuntimeException("VarInt too big") + if (k and 0x80 != 128) break + } + return i +} + +private fun writeVarInt(out: DataOutputStream, paramInt: Int) { + var paramInts = paramInt + while (true) { + if (paramInts and -0x80 == 0) { + out.writeByte(paramInts) + return + } + out.writeByte(paramInts and 0x7F or 0x80) + paramInts = paramInts ushr 7 + } +} + +private val JSON = Json { + ignoreUnknownKeys = true +} + +fun fetchData(address: InetSocketAddress, timeout: Int = 7000): StatusResponse { + val socket = Socket() + socket.setSoTimeout(timeout) + socket.connect(address, timeout) + val outputStream = socket.getOutputStream() + val dataOutputStream = DataOutputStream(outputStream) + val inputStream = socket.getInputStream() + val inputStreamReader = InputStreamReader(inputStream) + val b = ByteArrayOutputStream() + val handshake = DataOutputStream(b) + handshake.writeByte(0x00) //packet id for handshake + writeVarInt(handshake, 4) //protocol version + writeVarInt(handshake, address.hostString.length) //host length + handshake.writeBytes(address.hostString) //host string + handshake.writeShort(address.port) //port + writeVarInt(handshake, 1) //state (1 for handshake) + writeVarInt(dataOutputStream, b.size()) //prepend size + dataOutputStream.write(b.toByteArray()) //write handshake packet + dataOutputStream.writeByte(0x01) //size is only 1 + dataOutputStream.writeByte(0x00) //packet id for ping + val dataInputStream = DataInputStream(inputStream) + readVarInt(dataInputStream) //size of packet + var id = readVarInt(dataInputStream) //packet id + if (id == -1) { + throw IOException("Premature end of stream.") + } + if (id != 0x00) { //we want a status response + throw IOException("Invalid packetID") + } + val length = readVarInt(dataInputStream) //length of json string + if (length == -1) { + throw IOException("Premature end of stream.") + } + if (length == 0) { + throw IOException("Invalid string length.") + } + val `in` = ByteArray(length) + dataInputStream.readFully(`in`) //read json string + val json = String(`in`) + val now = System.currentTimeMillis() + dataOutputStream.writeByte(0x09) //size of packet + dataOutputStream.writeByte(0x01) //0x01 for ping + dataOutputStream.writeLong(now) //time!? + readVarInt(dataInputStream) + id = readVarInt(dataInputStream) + if (id == -1) { + throw IOException("Premature end of stream.") + } + if (id != 0x01) { + throw IOException("Invalid packetID") + } + val pingtime = dataInputStream.readLong() //read response + val response: StatusResponse = JSON.decodeFromString(json) + response.time = (now - pingtime).toInt() + dataOutputStream.close() + outputStream.close() + inputStreamReader.close() + inputStream.close() + socket.close() + return response +} + +@Serializable +data class StatusResponse(val description: JsonElement, val players: Players, val version: Version, val favicon: String) { + @Transient + var time = 0 +} + +@Serializable +data class Players(val max: Int, val online: Int, val sample: List = emptyList()) + +@Serializable +data class Player(val name: String, val id: String) + +@Serializable +data class Version(val name: String, val protocol: Int) \ No newline at end of file diff --git a/WebsiteBackend/src/logback.xml b/WebsiteBackend/src/logback.xml new file mode 100644 index 00000000..287d6348 --- /dev/null +++ b/WebsiteBackend/src/logback.xml @@ -0,0 +1,48 @@ + + + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + logs/${LOG_FILE}.log + + + logs/${LOG_FILE}.%d{yyyy-MM-dd}.gz + + + 30 + 1GB + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index ad769fd8..7c34b9b7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -140,6 +140,29 @@ dependencyResolutionManagement { library("velocityapi", "com.velocitypowered:velocity-api:3.3.0-SNAPSHOT") library("apolloapi", "com.lunarclient:apollo-api:1.1.0") library("apollocommon", "com.lunarclient:apollo-common:1.1.0") + + library("logback", "ch.qos.logback:logback-classic:1.5.6") + + val ktorVersion = "2.3.12" + + library("ktor", "io.ktor:ktor-server-core-jvm:$ktorVersion") + library("ktorContentNegotiation", "io.ktor:ktor-server-content-negotiation-jvm:$ktorVersion") + library("ktorCors", "io.ktor:ktor-server-cors-jvm:$ktorVersion") + library("ktorSerialization", "io.ktor:ktor-serialization-kotlinx-json-jvm:$ktorVersion") + library("ktorNetty", "io.ktor:ktor-server-netty-jvm:$ktorVersion") + library("ktorHost", "io.ktor:ktor-server-host-common-jvm:$ktorVersion") + library("ktorRequestValidation", "io.ktor:ktor-server-request-validation:$ktorVersion") + library("ktorAuth", "io.ktor:ktor-server-auth:$ktorVersion") + library("ktorAuthJvm", "io.ktor:ktor-server-auth-jvm:$ktorVersion") + library("ktorAuthLdap", "io.ktor:ktor-server-auth-ldap-jvm:$ktorVersion") + library("ktorClientCore", "io.ktor:ktor-client-core-jvm:$ktorVersion") + library("ktorClientJava", "io.ktor:ktor-client-java:$ktorVersion") + library("ktorClientContentNegotiation", "io.ktor:ktor-client-content-negotiation:$ktorVersion") + library("ktorClientAuth", "io.ktor:ktor-client-auth:$ktorVersion") + + library("yamlconfig", "org.bspfsystems:yamlconfiguration:1.3.0") + library("kotlinxSerializationCbor", "org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.4.1") + library("ktorRateLimit", "io.ktor:ktor-server-rate-limit:$ktorVersion") } } } @@ -218,3 +241,4 @@ include( ) include("TNTLeague") +include("WebsiteBackend") diff --git a/steamwarci.yml b/steamwarci.yml index c2769abd..8fbd084b 100644 --- a/steamwarci.yml +++ b/steamwarci.yml @@ -27,4 +27,6 @@ artifacts: "/binarys/PersistentVelocityCore.jar": "VelocityCore/Persistent/build/libs/Persistent.jar" "/binarys/VelocityCore.jar": "VelocityCore/build/libs/VelocityCore-all.jar" - "/binarys/deployarena.py": "VelocityCore/deployarena.py" \ No newline at end of file + "/binarys/deployarena.py": "VelocityCore/deployarena.py" + + "/binarys/website-api.jar": "WebsiteBackend/build/libs/api.jar" \ No newline at end of file