diff --git a/BauSystem/BauSystem_Main/src/de/steamwar/bausystem/features/world/BauScoreboard.java b/BauSystem/BauSystem_Main/src/de/steamwar/bausystem/features/world/BauScoreboard.java index 66435414..5bd1e06d 100644 --- a/BauSystem/BauSystem_Main/src/de/steamwar/bausystem/features/world/BauScoreboard.java +++ b/BauSystem/BauSystem_Main/src/de/steamwar/bausystem/features/world/BauScoreboard.java @@ -56,7 +56,7 @@ public class BauScoreboard implements Listener { public void handlePlayerJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); - SWScoreboard.createScoreboard(player, new ScoreboardCallback() { + SWScoreboard.impl.createScoreboard(player, new ScoreboardCallback() { @Override public HashMap getData() { Region region = Region.getRegion(player.getLocation()); 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..72497525 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 Integer 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..b3792b63 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.selectFields("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..96974c61 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 getAll.listSelect(); + } } diff --git a/CommonCore/SQL/src/de/steamwar/sql/UserPerm.java b/CommonCore/SQL/src/de/steamwar/sql/UserPerm.java index 0aca0319..ed842d54 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.insertAll(); + 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/FightSystem/FightSystem_Core/src/de/steamwar/fightsystem/listener/FightScoreboard.java b/FightSystem/FightSystem_Core/src/de/steamwar/fightsystem/listener/FightScoreboard.java index 63ca402f..7de1f306 100644 --- a/FightSystem/FightSystem_Core/src/de/steamwar/fightsystem/listener/FightScoreboard.java +++ b/FightSystem/FightSystem_Core/src/de/steamwar/fightsystem/listener/FightScoreboard.java @@ -60,17 +60,17 @@ public class FightScoreboard implements Listener, ScoreboardCallback { private FightScoreboard(){ new StateDependentListener(ArenaMode.Replay, FightState.All, this); - Bukkit.getOnlinePlayers().forEach(player -> SWScoreboard.createScoreboard(player, this)); + Bukkit.getOnlinePlayers().forEach(player -> SWScoreboard.impl.createScoreboard(player, this)); } @EventHandler public void onPlayerJoin(PlayerJoinEvent event) { - SWScoreboard.createScoreboard(event.getPlayer(), this); + SWScoreboard.impl.createScoreboard(event.getPlayer(), this); } @EventHandler public void onPlayerQuit(PlayerQuitEvent event) { - SWScoreboard.removeScoreboard(event.getPlayer()); + SWScoreboard.impl.removeScoreboard(event.getPlayer()); } public void setTitle(String t) { diff --git a/MissileWars/src/de/steamwar/misslewars/FightScoreboard.java b/MissileWars/src/de/steamwar/misslewars/FightScoreboard.java index 8b16b403..ca217fe2 100644 --- a/MissileWars/src/de/steamwar/misslewars/FightScoreboard.java +++ b/MissileWars/src/de/steamwar/misslewars/FightScoreboard.java @@ -53,7 +53,7 @@ class FightScoreboard implements Listener { @EventHandler public void onPlayerJoin(PlayerJoinEvent event) { - SWScoreboard.createScoreboard(event.getPlayer(), new ScoreboardCallback() { + SWScoreboard.impl.createScoreboard(event.getPlayer(), new ScoreboardCallback() { @Override public String getTitle() { return "§eMissileWars"; @@ -81,7 +81,7 @@ class FightScoreboard implements Listener { @EventHandler public void onPlayerQuit(PlayerQuitEvent event) { - SWScoreboard.removeScoreboard(event.getPlayer()); + SWScoreboard.impl.removeScoreboard(event.getPlayer()); } static Scoreboard getScoreboard() { diff --git a/SpigotCore/SpigotCore_21/src/de/steamwar/scoreboard/SWScoreboard21.java b/SpigotCore/SpigotCore_21/src/de/steamwar/scoreboard/SWScoreboard21.java index 0834e166..019e3898 100644 --- a/SpigotCore/SpigotCore_21/src/de/steamwar/scoreboard/SWScoreboard21.java +++ b/SpigotCore/SpigotCore_21/src/de/steamwar/scoreboard/SWScoreboard21.java @@ -30,7 +30,7 @@ import org.bukkit.scoreboard.Scoreboard; import java.util.HashMap; import java.util.Map; -public class SWScoreboard21 implements SWScoreboard.ISWScoreboard { +public class SWScoreboard21 implements SWScoreboard { private static final HashMap playerBoards = new HashMap<>(); private static final String SIDEBAR = "sw-sidebar"; @@ -40,7 +40,7 @@ public class SWScoreboard21 implements SWScoreboard.ISWScoreboard { for(Map.Entry scoreboard : playerBoards.entrySet()) { render(scoreboard.getKey(), scoreboard.getValue()); } - }, 5, 10); + }, 10, 5); } private static void render(Player player, ScoreboardCallback callback) { diff --git a/SpigotCore/SpigotCore_8/src/de/steamwar/scoreboard/SWScoreboard8.java b/SpigotCore/SpigotCore_8/src/de/steamwar/scoreboard/SWScoreboard8.java index 3af0fc93..d65cf989 100644 --- a/SpigotCore/SpigotCore_8/src/de/steamwar/scoreboard/SWScoreboard8.java +++ b/SpigotCore/SpigotCore_8/src/de/steamwar/scoreboard/SWScoreboard8.java @@ -29,7 +29,7 @@ import org.bukkit.entity.Player; import java.util.HashMap; import java.util.Map; -public class SWScoreboard8 implements SWScoreboard.ISWScoreboard { +public class SWScoreboard8 implements SWScoreboard { private static final Reflection.FieldAccessor scoreboardName = Reflection.getField(FlatteningWrapper.scoreboardObjective, String.class, 0); private static final Reflection.FieldAccessor scoreboardAction = Reflection.getField(FlatteningWrapper.scoreboardObjective, int.class, Core.getVersion() > 15 ? 3 : 0); private static final Class scoreboardDisplayEnum = Reflection.getClass("{nms.world.scores.criteria}.IScoreboardCriteria$EnumScoreboardHealthDisplay"); diff --git a/SpigotCore/SpigotCore_Main/src/de/steamwar/scoreboard/SWScoreboard.java b/SpigotCore/SpigotCore_Main/src/de/steamwar/scoreboard/SWScoreboard.java index 5fd8808e..1d5c2d79 100644 --- a/SpigotCore/SpigotCore_Main/src/de/steamwar/scoreboard/SWScoreboard.java +++ b/SpigotCore/SpigotCore_Main/src/de/steamwar/scoreboard/SWScoreboard.java @@ -23,21 +23,9 @@ import de.steamwar.core.Core; import de.steamwar.core.VersionDependent; import org.bukkit.entity.Player; -public class SWScoreboard { - private SWScoreboard() {} +public interface SWScoreboard { + public static final SWScoreboard impl = VersionDependent.getVersionImpl(Core.getInstance()); - private static final ISWScoreboard impl = VersionDependent.getVersionImpl(Core.getInstance()); - - public static boolean createScoreboard(Player player, ScoreboardCallback callback) { - return impl.createScoreboard(player, callback); - } - - public static void removeScoreboard(Player player) { - impl.removeScoreboard(player); - } - - public interface ISWScoreboard { - boolean createScoreboard(Player player, ScoreboardCallback callback); - void removeScoreboard(Player player); - } + boolean createScoreboard(Player player, ScoreboardCallback callback); + void removeScoreboard(Player player); } diff --git a/TNTLeague/src/de/steamwar/tntleague/events/IngameListener.kt b/TNTLeague/src/de/steamwar/tntleague/events/IngameListener.kt index 4d6f6704..e48ea7e6 100644 --- a/TNTLeague/src/de/steamwar/tntleague/events/IngameListener.kt +++ b/TNTLeague/src/de/steamwar/tntleague/events/IngameListener.kt @@ -61,7 +61,7 @@ object IngameListener: Listener { @EventHandler fun onJoin(e: PlayerJoinEvent) { - SWScoreboard.createScoreboard(e.player, TNTLeagueScoreboard(e.player)) + SWScoreboard.impl.createScoreboard(e.player, TNTLeagueScoreboard(e.player)) } @EventHandler diff --git a/TNTLeague/src/de/steamwar/tntleague/game/TNTLeagueGame.kt b/TNTLeague/src/de/steamwar/tntleague/game/TNTLeagueGame.kt index 3a6ac9c5..27d9b123 100644 --- a/TNTLeague/src/de/steamwar/tntleague/game/TNTLeagueGame.kt +++ b/TNTLeague/src/de/steamwar/tntleague/game/TNTLeagueGame.kt @@ -76,7 +76,7 @@ object TNTLeagueGame { state = GameState.RUNNING - plugin.server.onlinePlayers.forEach { SWScoreboard.createScoreboard(it, TNTLeagueScoreboard(it)) } + plugin.server.onlinePlayers.forEach { SWScoreboard.impl.createScoreboard(it, TNTLeagueScoreboard(it)) } blueTeam.start() redTeam.start() @@ -120,7 +120,7 @@ object TNTLeagueGame { plugin.server.onlinePlayers.forEach { it.gameMode = GameMode.SPECTATOR - SWScoreboard.removeScoreboard(it) + SWScoreboard.impl.removeScoreboard(it) it.playSound(Sound.sound(org.bukkit.Sound.ENTITY_ENDER_DRAGON_DEATH.key, Sound.Source.MASTER, 1f, 1f)) } diff --git a/VelocityCore/src/de/steamwar/velocitycore/EventStarter.java b/VelocityCore/src/de/steamwar/velocitycore/EventStarter.java index 17d4f537..15c16b64 100644 --- a/VelocityCore/src/de/steamwar/velocitycore/EventStarter.java +++ b/VelocityCore/src/de/steamwar/velocitycore/EventStarter.java @@ -62,7 +62,7 @@ public class EventStarter { //Don't start EventServer if not the event bungee String command; - if(VelocityCore.get().getConfig().isEventmode() || next.getSpectatePort() == 0) { + if(VelocityCore.get().getConfig().isEventmode() || next.getSpectatePort() == null) { ServerStarter starter = new ServerStarter().event(next); starter.callback(subserver -> { diff --git a/VelocityCore/src/de/steamwar/velocitycore/mods/ModUtils.java b/VelocityCore/src/de/steamwar/velocitycore/mods/ModUtils.java index b6e68cb9..c1cf7bad 100644 --- a/VelocityCore/src/de/steamwar/velocitycore/mods/ModUtils.java +++ b/VelocityCore/src/de/steamwar/velocitycore/mods/ModUtils.java @@ -20,6 +20,7 @@ package de.steamwar.velocitycore.mods; import com.velocitypowered.api.proxy.Player; +import de.steamwar.sql.Punishment; import de.steamwar.velocitycore.VelocityCore; import de.steamwar.velocitycore.commands.PunishmentCommand; import de.steamwar.messages.Chatter; @@ -82,6 +83,7 @@ public class ModUtils { } if(max == ModType.RED) { + user.punish(Punishment.PunishmentType.Ban, Timestamp.from(Instant.now().plus(7, ChronoUnit.DAYS)), message, -1, false); PunishmentCommand.ban(user, Timestamp.from(Instant.now().plus(7, ChronoUnit.DAYS)), message, SteamwarUser.get(-1), false); VelocityCore.getLogger().log(Level.SEVERE, "%s %s wurde automatisch wegen der Mods %s gebannt.".formatted(user.getUserName(), user.getId(), modList)); } diff --git a/WebsiteBackend/build.gradle.kts b/WebsiteBackend/build.gradle.kts new file mode 100644 index 00000000..2cf527a7 --- /dev/null +++ b/WebsiteBackend/build.gradle.kts @@ -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 . + */ + +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") +} + +tasks.build { + finalizedBy(tasks.buildFatJar) +} + +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..23fc3066 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/Application.kt @@ -0,0 +1,60 @@ +/* + * 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() { + 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/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..a8b056eb --- /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://vzge.me/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://vzge.me/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..6d90339a --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/plugins/Auth.kt @@ -0,0 +1,128 @@ +/* + * 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} +} + """ + + SWException.log(msg, cause.stackTraceToString()) + call.response.headers.append("X-Caught", "1") + } + + onCallRespond { call -> + if (call.response.status()?.isSuccess() == true) { + return@onCallRespond + } + + val msg = """ + URI: ${call.request.uri} + Method: ${call.request.httpMethod.value} + Response: ${call.response.status()?.value} + IP: ${call.request.headers["X-Forwarded-For"] ?: call.request.local.remoteHost} + UserAgent: ${call.request.headers["User-Agent"]} + """.trimIndent() + + val stack = """ + Headers: + ${call.request.headers.entries().joinToString("\n ") { "${it.key}: ${it.value}" }} + Body: + ${call.request.receiveChannel()} + """.trimIndent() + + SWException.log(msg, stack) + } +} \ 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..db96a700 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/plugins/SteamWar.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.plugins + +import de.steamwar.routes.catchException +import de.steamwar.sql.SteamwarUser +import io.ktor.server.request.* +import java.util.UUID + +fun ApplicationRequest.getUser(key: String = "id"): SteamwarUser? { + return SteamwarUser.get(call.parameters[key]?.let { catchException { UUID.fromString(it) } } ?: 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..52238c32 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Auth.kt @@ -0,0 +1,94 @@ +/* + * 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 + } + + 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..64039974 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Data.kt @@ -0,0 +1,153 @@ +/* + * 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 name: String, val uuid: String, val prefix: String, val perms: List) { + constructor(user: SteamwarUser) : this(user.userName, user.uuid.toString(), user.prefix().chatPrefix, user.perms().map { it.name }) { + synchronized(cache) { + cache[user.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") { + route("/admin") { + install(SWPermissionCheck) { + mustAuth = true + permission = UserPerm.PREFIX_MODERATOR + } + get("/users") { + call.respond(SteamwarUser.getAll().map { ResponseUser(it) }) + } + 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("/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("/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("/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..e95ea89d --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/EventFights.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.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 fight = call.receiveFight() ?: 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(fight.fightID, true) + } else { + Groups.setGroup(fight.fightID, updateFight.group) + } + } + fight.update(start, spielmodus, map, teamBlue, teamRed, spectatePort) + call.respond(HttpStatusCode.OK, ResponseEventFight(fight)) + } + delete { + val fight = call.receiveFight() ?: return@delete + fight.delete() + call.respond(HttpStatusCode.OK) + } + + } + } +} + +suspend fun ApplicationCall.receiveFight(fieldName: String = "fight"): EventFight? { + val fightId = parameters[fieldName]?.toIntOrNull() + if (fightId == null) { + respond(HttpStatusCode.BadRequest, ResponseError("Invalid fight ID")) + return null + } + + val fight = EventFight.get(fightId) + if (fight == null) { + respond(HttpStatusCode.NotFound, ResponseError("Fight not found")) + return null + } + return fight +} \ 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..e351f3e4 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Events.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.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 event = call.receiveEvent() ?: 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 event = call.receiveEvent() ?: 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) + } + } + } +} + +suspend fun ApplicationCall.receiveEvent(fieldName: String = "event"): Event? { + val eventId = parameters[fieldName]?.toIntOrNull() + if (eventId == null) { + respond(HttpStatusCode.BadRequest, ResponseError("Invalid event ID")) + return null + } + + val event = Event.get(eventId) + if (event == null) { + respond(HttpStatusCode.NotFound, ResponseError("Event not found")) + return null + } + + return event +} \ 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..041d4b31 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Routes.kt @@ -0,0 +1,39 @@ +/* + * 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() + } + } +} \ 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..b873b97d --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Schematic.kt @@ -0,0 +1,155 @@ +/* + * 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.uuid }) +} + +@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 node = call.receiveSchematic() ?: return@get + + val user = call.principal()?.user + if(user != null && !node.accessibleByUser(user)) { + call.respond(HttpStatusCode.Forbidden) + SWException.log("User ${user.userName} tried to download schematic ${node.name} without permission", user.id.toString()) + return@get + } + + val data = NodeData.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 node = call.receiveSchematic() ?: return@get + + call.respond(ResponseSchematic(node)) + } + } + route("/schem") { + install(SWPermissionCheck) + + 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)) + } + } +} + +suspend fun ApplicationCall.receiveSchematic(fieldName: String = "code", delete: Boolean = false): SchematicNode? { + val code = parameters[fieldName] ?: run { + respond(HttpStatusCode.BadRequest) + return null + } + + val dl = NodeDownload.get(code) ?: run { + respond(HttpStatusCode.NotFound) + return null + } + + if(dl.timestamp.toInstant().plus(Duration.of(5, ChronoUnit.MINUTES)).isBefore(Instant.now())) { + respond(HttpStatusCode.Gone) + return null + } + + if (delete) { + dl.delete() + } + + val node = SchematicNode.getSchematicNode(dl.nodeId) ?: run { + respond(HttpStatusCode.NotFound) + return null + } + + return node +} diff --git a/WebsiteBackend/src/de/steamwar/routes/Stats.kt b/WebsiteBackend/src/de/steamwar/routes/Stats.kt new file mode 100644 index 00000000..0c4e571b --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/Stats.kt @@ -0,0 +1,76 @@ +/* + * 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") { + install(SWPermissionCheck) + get { + val user = call.authentication.principal() + + if (user == null) { + call.respond(HttpStatusCode.NotFound, "User not found") + return@get + } + + call.respond(UserStats(user.user)) + } + } + } +} \ 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..2befc916 --- /dev/null +++ b/WebsiteBackend/src/de/steamwar/routes/UserPerms.kt @@ -0,0 +1,130 @@ +/* + * 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 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) { + 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}") { + 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, prefix) = call.receivePermission("prefix") ?: return@put + + user.perms().filter { it.name.startsWith("PREFIX_") }.forEach { + UserPerm.removePerm(user, it) + } + + UserPerm.addPerm(user, UserPerm.entries.find { it == prefix }!!) + call.respond(HttpStatusCode.Accepted) + } + put("/{perm}") { + val (user, permission) = call.receivePermission() ?: return@put + + if (!user.hasPerm(permission)) { + UserPerm.addPerm(user, permission) + call.respond(HttpStatusCode.Accepted) + return@put + } + call.respond(HttpStatusCode.NoContent) + } + delete("/{perm}") { + val (user, permission) = call.receivePermission() ?: return@delete + + if (user.hasPerm(permission)) { + UserPerm.removePerm(user, permission) + call.respond(HttpStatusCode.Accepted) + return@delete + } + call.respond(HttpStatusCode.NoContent) + } + } + } +} + +suspend fun ApplicationCall.receivePermission(fieldName: String = "perm", isPrefix: Boolean = false): Pair? { + val user = request.getUser() + if (user == null) { + respond(HttpStatusCode.BadRequest) + return null + } + + val perm = parameters[fieldName] + val permission = UserPerm.entries.find { it.name == perm } + if (perm == null || perm.startsWith("PREFIX_") == isPrefix || permission == null) { + respond(HttpStatusCode.BadRequest) + return null + } + + return user to permission +} \ 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 2271e124..7af69840 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -141,6 +141,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") } } } @@ -220,3 +243,4 @@ include( ) include("TNTLeague") +include("WebsiteBackend") diff --git a/steamwarci.yml b/steamwarci.yml index c2769abd..19b1643c 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/WebsiteBackend-all.jar" \ No newline at end of file