diff --git a/src/main/java/de/ticketsystem/TicketPlugin.java b/src/main/java/de/ticketsystem/TicketPlugin.java index 1355b1d..b617c7d 100644 --- a/src/main/java/de/ticketsystem/TicketPlugin.java +++ b/src/main/java/de/ticketsystem/TicketPlugin.java @@ -95,7 +95,7 @@ public class TicketPlugin extends JavaPlugin { // Versionsprüfung der config.yml String configVersion = getConfig().getString("version", ""); - String expectedVersion = "2.4"; + String expectedVersion = "2.5"; if (!expectedVersion.equals(configVersion)) { getLogger().warning("[WARNUNG] config.yml-Version (" + configVersion + ") stimmt nicht mit der erwarteten Version (" + expectedVersion + ") überein!"); diff --git a/src/main/java/de/ticketsystem/commands/TicketCommand.java b/src/main/java/de/ticketsystem/commands/TicketCommand.java index a68746d..e60111d 100644 --- a/src/main/java/de/ticketsystem/commands/TicketCommand.java +++ b/src/main/java/de/ticketsystem/commands/TicketCommand.java @@ -52,6 +52,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter { case "importieren" -> "import"; case "statistik" -> "stats"; case "archivieren" -> "archive"; + case "sichern" -> "backup"; case "kommentar" -> "comment"; case "sperrliste" -> "blacklist"; case "bewerten" -> "rate"; @@ -90,6 +91,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter { case "stats" -> handleStats(player); case "top" -> handleTop(player); case "archive" -> handleArchive(player); + case "backup" -> handleBackup(player); case "comment" -> handleComment(player, args); case "blacklist" -> handleBlacklist(player, args); case "rate" -> handleRate(player, args); @@ -552,6 +554,26 @@ public class TicketCommand implements CommandExecutor, TabCompleter { player.sendMessage(plugin.lang().format("reload.bungee-info", "{server}", plugin.getServerName())); } + // ── /ticket backup ──────────────────────────────────────────────────── + + private void handleBackup(Player player) { + if (!player.hasPermission("ticket.admin")) { + plugin.lang().send(player, "general.no-permission"); return; + } + player.sendMessage(plugin.lang().get("backup.start")); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + java.io.File backup = plugin.getDatabaseManager().createBackup(); + Bukkit.getScheduler().runTask(plugin, () -> { + if (backup != null) { + player.sendMessage(plugin.lang().format("backup.success", + "{file}", backup.getName())); + } else { + player.sendMessage(plugin.lang().get("backup.fail")); + } + }); + }); + } + // ── /ticket archive ─────────────────────────────────────────────────── private void handleArchive(Player player) { @@ -835,6 +857,52 @@ public class TicketCommand implements CommandExecutor, TabCompleter { "{count}", String.valueOf(plugin.getFaqManager().getAll().size()))); } + case "migrate", "migrieren" -> { + if (!player.hasPermission("ticket.admin")) { + plugin.lang().send(player, "general.no-permission"); return; + } + // Richtung bestimmen: tofile oder tomysql (Standard) + String direction = args.length >= 3 ? args[2].toLowerCase() : "tomysql"; + + if (direction.equals("tofile") || direction.equals("zudatei")) { + // MySQL → faqs.yml + if (!plugin.getFaqManager().isUsingMySQL()) { + player.sendMessage(plugin.lang().get("faq.migrate-no-mysql")); return; + } + player.sendMessage(plugin.lang().get("faq.migrate-tofile-start")); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + int[] result = plugin.getFaqManager().migrateFaqToFile(); + Bukkit.getScheduler().runTask(plugin, () -> { + if (result != null) { + player.sendMessage(plugin.lang().format("faq.migrate-tofile-success", + "{cats}", String.valueOf(result[0]), + "{entries}", String.valueOf(result[1]))); + } else { + player.sendMessage(plugin.lang().get("faq.migrate-fail")); + } + }); + }); + } else { + // faqs.yml → MySQL + if (!plugin.getFaqManager().isUsingMySQL()) { + player.sendMessage(plugin.lang().get("faq.migrate-no-mysql")); return; + } + player.sendMessage(plugin.lang().get("faq.migrate-start")); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + int[] result = plugin.getFaqManager().migrateFaqToMySQL(); + Bukkit.getScheduler().runTask(plugin, () -> { + if (result != null) { + player.sendMessage(plugin.lang().format("faq.migrate-success", + "{cats}", String.valueOf(result[0]), + "{entries}", String.valueOf(result[1]))); + } else { + player.sendMessage(plugin.lang().get("faq.migrate-fail")); + } + }); + }); + } + } + case "list", "liste" -> { List all = plugin.getFaqManager().getAll(); player.sendMessage(plugin.lang().get("general.separator")); @@ -1045,8 +1113,8 @@ public class TicketCommand implements CommandExecutor, TabCompleter { if (useEn) faqSubs.add("list"); if (useDe) faqSubs.add("liste"); if (player.hasPermission("ticket.admin")) { - if (useEn) faqSubs.addAll(List.of("add", "edit", "delete", "reload")); - if (useDe) faqSubs.addAll(List.of("hinzufügen", "bearbeiten", "löschen", "neuladen")); + if (useEn) faqSubs.addAll(List.of("add", "edit", "delete", "reload", "migrate")); + if (useDe) faqSubs.addAll(List.of("hinzufügen", "bearbeiten", "löschen", "neuladen", "migrieren")); } for (String s : faqSubs) if (s.startsWith(args[1].toLowerCase())) completions.add(s); diff --git a/src/main/java/de/ticketsystem/database/DatabaseManager.java b/src/main/java/de/ticketsystem/database/DatabaseManager.java index 57fdd10..3dd1014 100644 --- a/src/main/java/de/ticketsystem/database/DatabaseManager.java +++ b/src/main/java/de/ticketsystem/database/DatabaseManager.java @@ -163,7 +163,59 @@ public class DatabaseManager { } } - private Connection getConnection() throws SQLException { return dataSource.getConnection(); } + private Connection getConnection() throws SQLException { + if (dataSource == null || dataSource.isClosed()) { + if (plugin != null) + plugin.getLogger().warning("[MySQL] DataSource nicht verfügbar – versuche Reconnect..."); + if (!reconnectMySQL()) { + throw new SQLException("MySQL-Verbindung nicht verfügbar und Reconnect fehlgeschlagen."); + } + } + return dataSource.getConnection(); + } + + /** + * Versucht die MySQL-Verbindung neu aufzubauen. + * Wird automatisch aufgerufen wenn getConnection() fehlschlägt. + * + * @return true wenn Reconnect erfolgreich + */ + public boolean reconnectMySQL() { + if (!useMySQL || plugin == null) return false; + try { + if (dataSource != null && !dataSource.isClosed()) dataSource.close(); + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(String.format( + "jdbc:mysql://%s:%d/%s?useSSL=false&autoReconnect=true&characterEncoding=UTF-8", + plugin.getConfig().getString("mysql.host"), + plugin.getConfig().getInt("mysql.port"), + plugin.getConfig().getString("mysql.database"))); + config.setUsername(plugin.getConfig().getString("mysql.username")); + config.setPassword(plugin.getConfig().getString("mysql.password")); + config.setMaximumPoolSize(plugin.getConfig().getInt("mysql.pool-size", 10)); + config.setConnectionTimeout(plugin.getConfig().getLong("mysql.connection-timeout", 30000)); + config.setPoolName("TicketSystem-Reconnect"); + config.addDataSourceProperty("cachePrepStmts", "true"); + config.addDataSourceProperty("prepStmtCacheSize", "250"); + config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + dataSource = new HikariDataSource(config); + plugin.getLogger().info("[MySQL] Reconnect erfolgreich."); + return true; + } catch (Exception e) { + plugin.getLogger().severe("[MySQL] Reconnect fehlgeschlagen: " + e.getMessage()); + return false; + } + } + + /** Gibt true zurück wenn die MySQL-Verbindung aktuell verfügbar ist. */ + public boolean isConnected() { + if (!useMySQL || dataSource == null || dataSource.isClosed()) return false; + try (Connection c = dataSource.getConnection()) { + return c.isValid(2); + } catch (SQLException e) { + return false; + } + } // ─────────────────────────── Tabellen erstellen ──────────────────────── @@ -257,6 +309,27 @@ public class DatabaseManager { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """; + // FAQ-Einträge (optional, nur wenn faq-storage: mysql) + String faqEntriesSql = """ + CREATE TABLE IF NOT EXISTS faq_entries ( + id INT AUTO_INCREMENT PRIMARY KEY, + question VARCHAR(512) NOT NULL, + answer TEXT NOT NULL, + category_key VARCHAR(64) NOT NULL DEFAULT '__none__', + sort_order INT NOT NULL DEFAULT 0 + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """; + + // FAQ-Kategorien (optional, nur wenn faq-storage: mysql) + String faqCategoriesSql = """ + CREATE TABLE IF NOT EXISTS faq_categories ( + cat_key VARCHAR(64) NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + color VARCHAR(8) NOT NULL DEFAULT '&7', + description VARCHAR(255) NOT NULL DEFAULT '' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """; + // Ausstehende BungeeCord-Teleport-Aufträge. // Wird gesetzt wenn ein Admin via GUI/Command auf einen anderen Server teleportiert. // PlayerJoinListener liest den Eintrag beim Ankommen, teleportiert, löscht ihn dann. @@ -295,6 +368,8 @@ public class DatabaseManager { stmt.execute(statsSql); stmt.execute(pendingTeleportSql); stmt.execute(creatorStatsSql); + stmt.execute(faqEntriesSql); + stmt.execute(faqCategoriesSql); } catch (SQLException e) { plugin.getLogger().log(Level.SEVERE, "Fehler beim Erstellen der Tabellen: " + e.getMessage(), e); } @@ -1287,6 +1362,296 @@ public class DatabaseManager { return removed; } + // ─────────────────────────── FAQ (MySQL) ─────────────────────────────── + + /** + * Gibt alle FAQ-Einträge aus der Datenbank zurück. + * Gibt eine leere Liste zurück wenn MySQL nicht aktiv ist. + */ + public List getFaqEntries() { + List list = new ArrayList<>(); + if (!useMySQL) return list; + String sql = "SELECT * FROM faq_entries ORDER BY sort_order ASC, id ASC"; + try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) { + ResultSet rs = stmt.executeQuery(sql); + while (rs.next()) { + de.ticketsystem.model.FaqEntry e = new de.ticketsystem.model.FaqEntry( + rs.getInt("id"), + rs.getString("question"), + rs.getString("answer")); + e.setCategoryKey(rs.getString("category_key")); + list.add(e); + } + } catch (SQLException e) { + if (plugin != null) plugin.getLogger().severe("[FAQ] Fehler beim Laden der FAQ-Einträge: " + e.getMessage()); + } + return list; + } + + /** + * Erstellt einen neuen FAQ-Eintrag und gibt die generierte ID zurück (-1 bei Fehler). + */ + public int createFaqEntry(String question, String answer, String categoryKey) { + if (!useMySQL) return -1; + String sql = "INSERT INTO faq_entries (question, answer, category_key) VALUES (?, ?, ?)"; + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + ps.setString(1, question); + ps.setString(2, answer); + ps.setString(3, categoryKey != null ? categoryKey : de.ticketsystem.manager.FaqManager.UNCATEGORIZED_KEY); + ps.executeUpdate(); + ResultSet rs = ps.getGeneratedKeys(); + if (rs.next()) return rs.getInt(1); + } catch (SQLException e) { + if (plugin != null) plugin.getLogger().severe("[FAQ] Fehler beim Erstellen des FAQ-Eintrags: " + e.getMessage()); + } + return -1; + } + + /** + * Aktualisiert Frage und Antwort eines FAQ-Eintrags. + */ + public boolean updateFaqEntry(int id, String question, String answer) { + if (!useMySQL) return false; + String sql = "UPDATE faq_entries SET question = ?, answer = ? WHERE id = ?"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, question); + ps.setString(2, answer); + ps.setInt(3, id); + return ps.executeUpdate() > 0; + } catch (SQLException e) { + if (plugin != null) plugin.getLogger().severe("[FAQ] Fehler beim Aktualisieren des FAQ-Eintrags: " + e.getMessage()); + } + return false; + } + + /** + * Setzt die Kategorie eines FAQ-Eintrags. + */ + public boolean updateFaqEntryCategory(int id, String categoryKey) { + if (!useMySQL) return false; + String sql = "UPDATE faq_entries SET category_key = ? WHERE id = ?"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, categoryKey != null ? categoryKey : de.ticketsystem.manager.FaqManager.UNCATEGORIZED_KEY); + ps.setInt(2, id); + return ps.executeUpdate() > 0; + } catch (SQLException e) { + if (plugin != null) plugin.getLogger().severe("[FAQ] Fehler beim Setzen der FAQ-Kategorie: " + e.getMessage()); + } + return false; + } + + /** + * Löscht einen FAQ-Eintrag. + */ + public boolean deleteFaqEntry(int id) { + if (!useMySQL) return false; + String sql = "DELETE FROM faq_entries WHERE id = ?"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, id); + return ps.executeUpdate() > 0; + } catch (SQLException e) { + if (plugin != null) plugin.getLogger().severe("[FAQ] Fehler beim Löschen des FAQ-Eintrags: " + e.getMessage()); + } + return false; + } + + /** + * Gibt alle FAQ-Kategorien aus der Datenbank zurück. + */ + public List getFaqCategories() { + List list = new ArrayList<>(); + if (!useMySQL) return list; + String sql = "SELECT * FROM faq_categories ORDER BY cat_key ASC"; + try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) { + ResultSet rs = stmt.executeQuery(sql); + while (rs.next()) { + list.add(new de.ticketsystem.model.FaqCategory( + rs.getString("cat_key"), + rs.getString("name"), + rs.getString("color"), + rs.getString("description"))); + } + } catch (SQLException e) { + if (plugin != null) plugin.getLogger().severe("[FAQ] Fehler beim Laden der FAQ-Kategorien: " + e.getMessage()); + } + return list; + } + + /** + * Erstellt eine FAQ-Kategorie. Gibt false zurück wenn der Key bereits existiert. + */ + public boolean createFaqCategory(String key, String name, String color, String description) { + if (!useMySQL) return false; + String sql = "INSERT IGNORE INTO faq_categories (cat_key, name, color, description) VALUES (?, ?, ?, ?)"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, key.toLowerCase()); + ps.setString(2, name); + ps.setString(3, color != null ? color : "&7"); + ps.setString(4, description != null ? description : ""); + return ps.executeUpdate() > 0; + } catch (SQLException e) { + if (plugin != null) plugin.getLogger().severe("[FAQ] Fehler beim Erstellen der FAQ-Kategorie: " + e.getMessage()); + } + return false; + } + + /** + * Aktualisiert eine FAQ-Kategorie. + */ + public boolean updateFaqCategory(String key, String name, String color, String description) { + if (!useMySQL) return false; + String sql = "UPDATE faq_categories SET name = ?, color = ?, description = ? WHERE cat_key = ?"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, name); + ps.setString(2, color != null ? color : "&7"); + ps.setString(3, description != null ? description : ""); + ps.setString(4, key.toLowerCase()); + return ps.executeUpdate() > 0; + } catch (SQLException e) { + if (plugin != null) plugin.getLogger().severe("[FAQ] Fehler beim Aktualisieren der FAQ-Kategorie: " + e.getMessage()); + } + return false; + } + + /** + * Löscht eine FAQ-Kategorie und setzt alle zugehörigen Einträge auf UNCATEGORIZED. + */ + public boolean deleteFaqCategory(String key) { + if (!useMySQL) return false; + String lowerKey = key.toLowerCase(); + try (Connection conn = getConnection()) { + // Einträge dieser Kategorie auf __none__ setzen + try (PreparedStatement ps = conn.prepareStatement( + "UPDATE faq_entries SET category_key = ? WHERE category_key = ?")) { + ps.setString(1, de.ticketsystem.manager.FaqManager.UNCATEGORIZED_KEY); + ps.setString(2, lowerKey); + ps.executeUpdate(); + } + // Kategorie löschen + try (PreparedStatement ps = conn.prepareStatement( + "DELETE FROM faq_categories WHERE cat_key = ?")) { + ps.setString(1, lowerKey); + return ps.executeUpdate() > 0; + } + } catch (SQLException e) { + if (plugin != null) plugin.getLogger().severe("[FAQ] Fehler beim Löschen der FAQ-Kategorie: " + e.getMessage()); + } + return false; + } + + /** + * Liest alle archivierten Tickets aus der Archiv-Datei (archive.json / archive.yml). + * Funktioniert sowohl im MySQL- als auch im Datei-Modus, da das Archiv + * immer als JSON-Datei im Plugin-Ordner gespeichert wird. + * + * @return Liste aller archivierten Tickets (leer wenn kein Archiv vorhanden) + */ + public List getArchivedTickets() { + List list = new ArrayList<>(); + File archiveFile = new File(plugin.getDataFolder(), archiveFileName); + if (!archiveFile.exists()) return list; + + try (FileReader fr = new FileReader(archiveFile)) { + Object parsed = new JSONParser().parse(fr); + if (parsed instanceof JSONArray arr) { + for (Object o : arr) { + if (o instanceof JSONObject obj) { + Ticket t = ticketFromJson(obj); + if (t != null) list.add(t); + } + } + } + } catch (Exception e) { + if (plugin != null) plugin.getLogger().severe("[TicketSystem] Fehler beim Laden des Archivs: " + e.getMessage()); + } + return list; + } + + /** + * Löscht ein Ticket permanent aus dem Archiv. + * + * @param ticketId ID des zu löschenden Tickets + * @return true wenn gefunden und gelöscht + */ + @SuppressWarnings("unchecked") + public boolean deleteArchivedTicket(int ticketId) { + File archiveFile = new File(plugin.getDataFolder(), archiveFileName); + if (!archiveFile.exists()) return false; + + try (FileReader fr = new FileReader(archiveFile)) { + Object parsed = new JSONParser().parse(fr); + if (!(parsed instanceof JSONArray arr)) return false; + + JSONArray updated = new JSONArray(); + boolean removed = false; + for (Object o : arr) { + if (o instanceof JSONObject obj) { + long id = obj.get("id") instanceof Long l ? l : ((Number) obj.get("id")).longValue(); + if (id == ticketId) { removed = true; continue; } + updated.add(obj); + } + } + if (!removed) return false; + + try (FileWriter fw = new FileWriter(archiveFile)) { + fw.write(updated.toJSONString()); + } + return true; + } catch (Exception e) { + if (plugin != null) plugin.getLogger().severe("[Archiv] Fehler beim Löschen: " + e.getMessage()); + return false; + } + } + + /** + * Stellt ein archiviertes Ticket wieder her (zurück in die aktive Datenbank). + * Das Ticket wird aus dem Archiv entfernt und neu als CLOSED-Ticket angelegt. + * + * @param ticketId ID des wiederherzustellenden Tickets + * @return true wenn erfolgreich wiederhergestellt + */ + @SuppressWarnings("unchecked") + public boolean restoreArchivedTicket(int ticketId) { + File archiveFile = new File(plugin.getDataFolder(), archiveFileName); + if (!archiveFile.exists()) return false; + + try (FileReader fr = new FileReader(archiveFile)) { + Object parsed = new JSONParser().parse(fr); + if (!(parsed instanceof JSONArray arr)) return false; + + JSONObject found = null; + JSONArray updated = new JSONArray(); + for (Object o : arr) { + if (o instanceof JSONObject obj) { + long id = obj.get("id") instanceof Long l ? l : ((Number) obj.get("id")).longValue(); + if (id == ticketId) { found = obj; } + else updated.add(obj); + } + } + if (found == null) return false; + + Ticket t = ticketFromJson(found); + if (t == null) return false; + + // Ticket in aktive DB zurückschreiben + int newId = createTicket(t); + if (newId == -1) return false; + + // Aus Archiv entfernen + try (FileWriter fw = new FileWriter(archiveFile)) { + fw.write(updated.toJSONString()); + } + + if (plugin != null) + plugin.getLogger().info("[Archiv] Ticket #" + ticketId + " wiederhergestellt als #" + newId); + return true; + } catch (Exception e) { + if (plugin != null) plugin.getLogger().severe("[Archiv] Fehler beim Wiederherstellen: " + e.getMessage()); + return false; + } + } + // ─────────────────────────── Statistiken ─────────────────────────────── public TicketStats getTicketStats() { @@ -1546,7 +1911,73 @@ public class DatabaseManager { catch (IOException e) { if (plugin != null) plugin.getLogger().severe("Fehler beim Speichern von " + dataFileName + ": " + e.getMessage()); } } - // ─────────────────────────── Backup (Platzhalter) ────────────────────── - private void backupMySQL() {} - private void backupDataFile() {} + // ─────────────────────────── Backup ──────────────────────────────────── + + /** + * Erstellt ein Backup der MySQL-Datenbank als JSON-Datei im Plugin-Ordner. + * Dateiname: backup_tickets_YYYY-MM-DD_HH-mm-ss.json + * Exportiert alle Tickets aus der aktiven tickets-Tabelle. + * + * @return Pfad zur Backup-Datei oder null bei Fehler + */ + public File backupMySQL() { + if (!useMySQL) return null; + String timestamp = new java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss") + .format(new java.util.Date()); + File backupDir = new File(plugin.getDataFolder(), "backups"); + backupDir.mkdirs(); + File backupFile = new File(backupDir, "backup_tickets_" + timestamp + ".json"); + + List tickets = getAllTickets(); + JSONArray arr = new JSONArray(); + for (Ticket t : tickets) arr.add(ticketToJson(t)); + + try (FileWriter fw = new FileWriter(backupFile)) { + fw.write(arr.toJSONString()); + if (plugin != null) + plugin.getLogger().info("[Backup] MySQL-Backup erstellt: " + backupFile.getName() + + " (" + tickets.size() + " Tickets)"); + return backupFile; + } catch (IOException e) { + if (plugin != null) + plugin.getLogger().severe("[Backup] Fehler beim MySQL-Backup: " + e.getMessage()); + return null; + } + } + + /** + * Erstellt ein Backup der aktuellen data.yml / data.json im Plugin-Ordner. + * Dateiname: backup_tickets_YYYY-MM-DD_HH-mm-ss.yml (bzw. .json) + * + * @return Pfad zur Backup-Datei oder null bei Fehler + */ + public File backupDataFile() { + if (useMySQL || dataFile == null || !dataFile.exists()) return null; + String timestamp = new java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss") + .format(new java.util.Date()); + String ext = dataFileName.endsWith(".json") ? ".json" : ".yml"; + File backupDir = new File(plugin.getDataFolder(), "backups"); + backupDir.mkdirs(); + File backupFile = new File(backupDir, "backup_tickets_" + timestamp + ext); + + try { + java.nio.file.Files.copy(dataFile.toPath(), backupFile.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + if (plugin != null) + plugin.getLogger().info("[Backup] Datei-Backup erstellt: " + backupFile.getName()); + return backupFile; + } catch (IOException e) { + if (plugin != null) + plugin.getLogger().severe("[Backup] Fehler beim Datei-Backup: " + e.getMessage()); + return null; + } + } + + /** + * Erstellt automatisch ein Backup (MySQL oder Datei) und gibt den Dateipfad zurück. + * Wird z.B. vor Migrationen aufgerufen. + */ + public File createBackup() { + return useMySQL ? backupMySQL() : backupDataFile(); + } } \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/manager/FaqManager.java b/src/main/java/de/ticketsystem/manager/FaqManager.java index 1f7c7c0..fcea39d 100644 --- a/src/main/java/de/ticketsystem/manager/FaqManager.java +++ b/src/main/java/de/ticketsystem/manager/FaqManager.java @@ -1,6 +1,7 @@ package de.ticketsystem.manager; import de.ticketsystem.TicketPlugin; +import de.ticketsystem.database.DatabaseManager; import de.ticketsystem.model.FaqCategory; import de.ticketsystem.model.FaqEntry; import org.bukkit.configuration.ConfigurationSection; @@ -11,54 +12,93 @@ import java.io.IOException; import java.util.*; /** - * Manages FAQ entries and FAQ categories stored in faqs.yml. + * Verwaltet FAQ-Einträge und FAQ-Kategorien. * - * faqs.yml wird beim ersten Start automatisch mit Beispiel-Kategorien und -FAQs generiert. - * Admins können Kategorien und FAQs direkt in-game verwalten (GUI + Befehle). + * Speichermodus wird in config.yml festgelegt: * - * faqs.yml Layout: + * faq-storage: file → faqs.yml (Standard, Einzelserver) + * faq-storage: mysql → MySQL-Datenbank (BungeeCord / Multi-Server, use-mysql muss true sein) * - * categories: - * tickets: - * name: "Tickets" - * color: "&b" - * description: "Fragen zum Ticket-System" + * Im MySQL-Modus werden Tabellen faq_entries und faq_categories verwendet. + * Im Datei-Modus verhält sich der Manager exakt wie bisher (faqs.yml). * - * faqs: - * 1: - * question: "Wie erstelle ich ein Ticket?" - * answer: "Nutze /ticket create ..." - * category: "tickets" - * - * Material und Textur der Kategorie-Items werden NICHT hier gespeichert – - * sie werden zentral in config.yml unter gui-settings.faq.category-head-item gesteuert. + * Die öffentliche API (add, edit, delete, getAll …) ist in beiden Modi identisch – + * FaqGUI, FaqHandler und ApiHandler müssen nicht angepasst werden. */ public class FaqManager { public static final String UNCATEGORIZED_KEY = "__none__"; private final TicketPlugin plugin; - private final File faqFile; - private YamlConfiguration faqConfig; + private final boolean useMysql; + // ── YAML-Modus ──────────────────────────────────────────────────────── + private final File faqFile; + private YamlConfiguration faqConfig; + + // ── In-Memory-State (beide Modi) ────────────────────────────────────── private final List entries = new ArrayList<>(); private final LinkedHashMap categories = new LinkedHashMap<>(); + /** Nur im YAML-Modus relevant – im MySQL-Modus liefert AUTO_INCREMENT die ID */ private int nextId = 1; public FaqManager(TicketPlugin plugin) { this.plugin = plugin; this.faqFile = new File(plugin.getDataFolder(), "faqs.yml"); + + // Speichermodus direkt aus use-mysql lesen – kein separater faq-storage Key nötig + this.useMysql = plugin.getConfig().getBoolean("use-mysql", false); + + if (useMysql) { + plugin.getLogger().info("[FaqManager] Speichermodus: MySQL (faq_entries / faq_categories)"); + } else { + plugin.getLogger().info("[FaqManager] Speichermodus: Datei (faqs.yml)"); + } + load(); } - // ─────────────────────────── Loading & Saving ─────────────────────────── + // ═══════════════════════════════════════════════════════════════════════ + // LADEN & SPEICHERN + // ═══════════════════════════════════════════════════════════════════════ private void load() { entries.clear(); categories.clear(); nextId = 1; + if (useMysql) { + loadFromMySQL(); + } else { + loadFromFile(); + } + } + + // ── MySQL-Modus ──────────────────────────────────────────────────────── + + private void loadFromMySQL() { + DatabaseManager db = plugin.getDatabaseManager(); + + for (FaqCategory cat : db.getFaqCategories()) { + categories.put(cat.getKey(), cat); + } + + for (FaqEntry e : db.getFaqEntries()) { + e.setCategoryKey(normalizeCategoryKey(e.getCategoryKey())); + entries.add(e); + if (e.getId() >= nextId) nextId = e.getId() + 1; + } + + if (plugin.isDebug()) { + plugin.getLogger().info("[FaqManager] MySQL: " + categories.size() + + " Kategorie(n), " + entries.size() + " FAQ(s) geladen."); + } + } + + // ── Datei-Modus ──────────────────────────────────────────────────────── + + private void loadFromFile() { if (!faqFile.exists()) { try { faqFile.getParentFile().mkdirs(); @@ -68,13 +108,12 @@ public class FaqManager { } faqConfig = new YamlConfiguration(); loadDefaults(); - save(); + saveToFile(); return; } faqConfig = YamlConfiguration.loadConfiguration(faqFile); - // ── 1. Kategorien laden ─────────────────────────────────────────── ConfigurationSection catSection = faqConfig.getConfigurationSection("categories"); if (catSection != null && !catSection.getKeys(false).isEmpty()) { for (String key : catSection.getKeys(false)) { @@ -85,13 +124,8 @@ public class FaqManager { String desc = cat.getString("description", ""); categories.put(key.toLowerCase(), new FaqCategory(key, name, color, desc)); } - if (plugin.isDebug()) { - plugin.getLogger().info("[FaqManager] " + categories.size() - + " FAQ-Kategorie(n) geladen: " + String.join(", ", categories.keySet())); - } } - // ── 2. FAQ-Einträge laden ───────────────────────────────────────── ConfigurationSection faqSection = faqConfig.getConfigurationSection("faqs"); if (faqSection != null) { for (String key : faqSection.getKeys(false)) { @@ -113,7 +147,8 @@ public class FaqManager { entries.sort(Comparator.comparingInt(FaqEntry::getId)); if (plugin.isDebug()) { - plugin.getLogger().info("[FaqManager] " + entries.size() + " FAQ(s) geladen."); + plugin.getLogger().info("[FaqManager] Datei: " + categories.size() + + " Kategorie(n), " + entries.size() + " FAQ(s) geladen."); } } @@ -124,23 +159,23 @@ public class FaqManager { "Material/Textur der Kategorie-Items: config.yml → gui-settings.faq.category-head-item" ); - writeCategory("general", "Allgemein", "&e", "Allgemeine Fragen zum Server"); - writeCategory("rules", "Regeln", "&c", "Fragen zu den Server-Regeln"); - writeCategory("gameplay", "Gameplay", "&a", "Fragen zum Spielgeschehen"); - writeCategory("tickets", "Tickets", "&b", "Fragen zum Ticket-System"); + writeFileCategory("general", "Allgemein", "&e", "Allgemeine Fragen zum Server"); + writeFileCategory("rules", "Regeln", "&c", "Fragen zu den Server-Regeln"); + writeFileCategory("gameplay", "Gameplay", "&a", "Fragen zum Spielgeschehen"); + writeFileCategory("tickets", "Tickets", "&b", "Fragen zum Ticket-System"); categories.put("general", new FaqCategory("general", "Allgemein", "&e", "Allgemeine Fragen zum Server")); categories.put("rules", new FaqCategory("rules", "Regeln", "&c", "Fragen zu den Server-Regeln")); categories.put("gameplay", new FaqCategory("gameplay", "Gameplay", "&a", "Fragen zum Spielgeschehen")); categories.put("tickets", new FaqCategory("tickets", "Tickets", "&b", "Fragen zum Ticket-System")); - writeEntry(1, "Wie erstelle ich ein Ticket?", + writeFileEntry(1, "Wie erstelle ich ein Ticket?", "Nutze den Befehl /ticket create [Kategorie] [Prio] .", "tickets"); - writeEntry(2, "Wie lange dauert die Bearbeitung?", + writeFileEntry(2, "Wie lange dauert die Bearbeitung?", "Unser Support-Team bearbeitet Tickets so schnell wie möglich.", "tickets"); - writeEntry(3, "Kann ich mein Ticket löschen?", + writeFileEntry(3, "Kann ich mein Ticket löschen?", "Ja! Öffne /ticket list und klicke auf dein Ticket.", "tickets"); - writeEntry(4, "Wie kann ich meinen Support bewerten?", + writeFileEntry(4, "Wie kann ich meinen Support bewerten?", "Mit /ticket rate good/bad nach dem Schließen.", "tickets"); nextId = 5; @@ -155,13 +190,13 @@ public class FaqManager { // ── YAML-Hilfsmethoden ───────────────────────────────────────────────── - private void writeCategory(String key, String name, String color, String description) { + private void writeFileCategory(String key, String name, String color, String description) { faqConfig.set("categories." + key + ".name", name); faqConfig.set("categories." + key + ".color", color); faqConfig.set("categories." + key + ".description", description); } - private void writeEntry(int id, String question, String answer, String categoryKey) { + private void writeFileEntry(int id, String question, String answer, String categoryKey) { faqConfig.set("faqs." + id + ".question", question); faqConfig.set("faqs." + id + ".answer", answer); if (categoryKey != null && !categoryKey.equals(UNCATEGORIZED_KEY)) { @@ -169,7 +204,8 @@ public class FaqManager { } } - private void save() { + private void saveToFile() { + if (faqConfig == null) return; try { faqConfig.save(faqFile); } catch (IOException e) { @@ -177,7 +213,9 @@ public class FaqManager { } } - // ─────────────────────────── Public API – Kategorien ─────────────────── + // ═══════════════════════════════════════════════════════════════════════ + // PUBLIC API – KATEGORIEN + // ═══════════════════════════════════════════════════════════════════════ public boolean hasCategoriesEnabled() { return !categories.isEmpty(); } @@ -190,55 +228,61 @@ public class FaqManager { return categories.get(key.toLowerCase()); } - /** - * Fügt eine neue Kategorie hinzu und speichert sofort. - * - * @return null wenn der Schlüssel bereits existiert, sonst die neue FaqCategory. - */ public FaqCategory addCategory(String key, String name, String color, String description) { String lowerKey = key.toLowerCase().replaceAll("\\s+", "_"); if (categories.containsKey(lowerKey)) return null; + FaqCategory cat = new FaqCategory(lowerKey, name, color, description); categories.put(lowerKey, cat); - writeCategory(lowerKey, name, color, description); - save(); + + if (useMysql) { + plugin.getDatabaseManager().createFaqCategory(lowerKey, name, color, description); + } else { + writeFileCategory(lowerKey, name, color, description); + saveToFile(); + } return cat; } - /** - * Bearbeitet eine bestehende Kategorie und speichert sofort. - * - * @return true wenn gefunden und aktualisiert. - */ public boolean editCategory(String key, String name, String color, String description) { String lowerKey = key.toLowerCase(); if (!categories.containsKey(lowerKey)) return false; - FaqCategory updated = new FaqCategory(lowerKey, name, color, description); - categories.put(lowerKey, updated); - writeCategory(lowerKey, name, color, description); - save(); - return true; + + categories.put(lowerKey, new FaqCategory(lowerKey, name, color, description)); + + if (useMysql) { + return plugin.getDatabaseManager().updateFaqCategory(lowerKey, name, color, description); + } else { + writeFileCategory(lowerKey, name, color, description); + saveToFile(); + return true; + } } - /** - * Löscht eine Kategorie. FAQs dieser Kategorie werden auf UNCATEGORIZED_KEY gesetzt. - * - * @return true wenn gefunden und gelöscht. - */ public boolean deleteCategory(String key) { String lowerKey = key.toLowerCase(); if (!categories.containsKey(lowerKey)) return false; + categories.remove(lowerKey); - faqConfig.set("categories." + lowerKey, null); - // FAQs dieser Kategorie auf "keine Kategorie" setzen - for (FaqEntry entry : entries) { - if (lowerKey.equals(entry.getCategoryKey())) { - entry.setCategoryKey(UNCATEGORIZED_KEY); - faqConfig.set("faqs." + entry.getId() + ".category", null); + + if (useMysql) { + boolean ok = plugin.getDatabaseManager().deleteFaqCategory(lowerKey); + // In-Memory synchronisieren + entries.stream() + .filter(e -> lowerKey.equals(e.getCategoryKey())) + .forEach(e -> e.setCategoryKey(UNCATEGORIZED_KEY)); + return ok; + } else { + faqConfig.set("categories." + lowerKey, null); + for (FaqEntry entry : entries) { + if (lowerKey.equals(entry.getCategoryKey())) { + entry.setCategoryKey(UNCATEGORIZED_KEY); + faqConfig.set("faqs." + entry.getId() + ".category", null); + } } + saveToFile(); + return true; } - save(); - return true; } public List getByCategory(String categoryKey) { @@ -254,7 +298,9 @@ public class FaqManager { return getByCategory(categoryKey).size(); } - // ─────────────────────────── Public API – Einträge ───────────────────── + // ═══════════════════════════════════════════════════════════════════════ + // PUBLIC API – EINTRÄGE + // ═══════════════════════════════════════════════════════════════════════ public List getAll() { return Collections.unmodifiableList(entries); } @@ -263,14 +309,24 @@ public class FaqManager { } public FaqEntry add(String question, String answer, String categoryKey) { - int id = nextId++; String normalizedKey = normalizeCategoryKey(categoryKey); - FaqEntry entry = new FaqEntry(id, question, answer); - entry.setCategoryKey(normalizedKey); - entries.add(entry); - writeEntry(id, question, answer, normalizedKey); - save(); - return entry; + + if (useMysql) { + int id = plugin.getDatabaseManager().createFaqEntry(question, answer, normalizedKey); + if (id == -1) return null; + FaqEntry entry = new FaqEntry(id, question, answer); + entry.setCategoryKey(normalizedKey); + entries.add(entry); + return entry; + } else { + int id = nextId++; + FaqEntry entry = new FaqEntry(id, question, answer); + entry.setCategoryKey(normalizedKey); + entries.add(entry); + writeFileEntry(id, question, answer, normalizedKey); + saveToFile(); + return entry; + } } public FaqEntry add(String question, String answer) { @@ -280,39 +336,193 @@ public class FaqManager { public boolean edit(int id, String question, String answer) { FaqEntry entry = getById(id); if (entry == null) return false; + entry.setQuestion(question); entry.setAnswer(answer); - writeEntry(id, question, answer, entry.getCategoryKey()); - save(); - return true; + + if (useMysql) { + return plugin.getDatabaseManager().updateFaqEntry(id, question, answer); + } else { + writeFileEntry(id, question, answer, entry.getCategoryKey()); + saveToFile(); + return true; + } } public boolean setCategory(int id, String categoryKey) { FaqEntry entry = getById(id); if (entry == null) return false; + String normalizedKey = normalizeCategoryKey(categoryKey); entry.setCategoryKey(normalizedKey); - if (normalizedKey.equals(UNCATEGORIZED_KEY)) { - faqConfig.set("faqs." + id + ".category", null); + + if (useMysql) { + return plugin.getDatabaseManager().updateFaqEntryCategory(id, normalizedKey); } else { - faqConfig.set("faqs." + id + ".category", normalizedKey); + if (normalizedKey.equals(UNCATEGORIZED_KEY)) { + faqConfig.set("faqs." + id + ".category", null); + } else { + faqConfig.set("faqs." + id + ".category", normalizedKey); + } + saveToFile(); + return true; } - save(); - return true; } public boolean delete(int id) { FaqEntry entry = getById(id); if (entry == null) return false; + entries.remove(entry); - faqConfig.set("faqs." + id, null); - save(); - return true; + + if (useMysql) { + return plugin.getDatabaseManager().deleteFaqEntry(id); + } else { + faqConfig.set("faqs." + id, null); + saveToFile(); + return true; + } } public void reload() { load(); } - // ─────────────────────────── Hilfsmethoden ───────────────────────────── + /** Gibt zurück ob MySQL als FAQ-Speicher aktiv ist. */ + public boolean isUsingMySQL() { return useMysql; } + + /** + * Migriert FAQ-Daten aus faqs.yml in die MySQL-Datenbank. + * + * Nur ausführbar wenn use-mysql: true aktiv ist. + * Nach erfolgreichem Import wird der In-Memory-Cache neu geladen. + * + * @return int[2] { importierte Kategorien, importierte Einträge } oder null bei Fehler + */ + public int[] migrateFaqToMySQL() { + if (!useMysql) { + plugin.getLogger().warning("[FaqManager] Migration nur im MySQL-Modus möglich (use-mysql: true)."); + return null; + } + if (!faqFile.exists()) { + plugin.getLogger().warning("[FaqManager] faqs.yml nicht gefunden – Migration abgebrochen."); + return null; + } + + try { + YamlConfiguration yml = YamlConfiguration.loadConfiguration(faqFile); + DatabaseManager db = plugin.getDatabaseManager(); + + // Bestehende DB-Einträge als Duplikat-Schutz laden + java.util.Set existingKeys = new java.util.HashSet<>(); + for (FaqEntry e : db.getFaqEntries()) { + existingKeys.add(e.getQuestion().trim().toLowerCase() + + "\u0000" + e.getAnswer().trim().toLowerCase()); + } + + // Kategorien importieren + int importedCats = 0; + ConfigurationSection catSec = yml.getConfigurationSection("categories"); + if (catSec != null) { + for (String key : catSec.getKeys(false)) { + String name = yml.getString("categories." + key + ".name", key); + String color = yml.getString("categories." + key + ".color", "&7"); + String desc = yml.getString("categories." + key + ".description", ""); + if (db.createFaqCategory(key.toLowerCase(), name, color, desc)) importedCats++; + } + } + + // Einträge importieren — Duplikat-Prüfung per ID (nicht nur Inhalt) + int importedEntries = 0; + java.util.Set existingIds = new java.util.HashSet<>(); + for (FaqEntry e : db.getFaqEntries()) existingIds.add(e.getId()); + + ConfigurationSection faqSec = yml.getConfigurationSection("faqs"); + if (faqSec != null) { + for (String key : faqSec.getKeys(false)) { + String question = yml.getString("faqs." + key + ".question", ""); + String answer = yml.getString("faqs." + key + ".answer", ""); + String category = yml.getString("faqs." + key + ".category", null); + if (question.isBlank() || answer.isBlank()) continue; + // Duplikat-Check: gleiche Frage UND Antwort (normalisiert) + String dupKey = question.trim().toLowerCase() + "\u0000" + answer.trim().toLowerCase(); + if (existingKeys.contains(dupKey)) continue; + int id = db.createFaqEntry(question, answer, + category != null ? category : UNCATEGORIZED_KEY); + if (id != -1) { importedEntries++; existingKeys.add(dupKey); } + } + } + + plugin.getLogger().info("[FaqManager] Migration: " + importedCats + + " Kategorie(n), " + importedEntries + " Eintrag/Einträge importiert."); + + load(); + return new int[]{importedCats, importedEntries}; + + } catch (Exception e) { + plugin.getLogger().severe("[FaqManager] Fehler bei Migration: " + e.getMessage()); + return null; + } + } + + /** + * Exportiert alle FAQ-Daten aus MySQL zurück in die faqs.yml. + * Nützlich beim Wechsel von MySQL zurück auf Datei-Modus. + * Überschreibt eine bestehende faqs.yml nach Sicherheits-Backup. + * + * @return int[2] { exportierte Kategorien, exportierte Einträge } oder null bei Fehler + */ + public int[] migrateFaqToFile() { + if (!useMysql) { + plugin.getLogger().warning("[FaqManager] Rückmigration nur im MySQL-Modus möglich."); + return null; + } + try { + DatabaseManager db = plugin.getDatabaseManager(); + + // Backup der bestehenden faqs.yml + if (faqFile.exists()) { + String ts = new java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss") + .format(new java.util.Date()); + java.io.File backup = new java.io.File(faqFile.getParent(), "faqs_backup_" + ts + ".yml"); + java.nio.file.Files.copy(faqFile.toPath(), backup.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + plugin.getLogger().info("[FaqManager] Backup erstellt: " + backup.getName()); + } + + YamlConfiguration out = new YamlConfiguration(); + + // Kategorien schreiben + int exportedCats = 0; + for (FaqCategory cat : db.getFaqCategories()) { + out.set("categories." + cat.getKey() + ".name", cat.getName()); + out.set("categories." + cat.getKey() + ".color", cat.getColor()); + out.set("categories." + cat.getKey() + ".description", cat.getDescription()); + exportedCats++; + } + + // Einträge schreiben + int exportedEntries = 0; + for (FaqEntry e : db.getFaqEntries()) { + String base = "faqs." + e.getId(); + out.set(base + ".question", e.getQuestion()); + out.set(base + ".answer", e.getAnswer()); + if (e.hasCategory()) out.set(base + ".category", e.getCategoryKey()); + exportedEntries++; + } + + out.save(faqFile); + plugin.getLogger().info("[FaqManager] Rückmigration: " + exportedCats + + " Kategorie(n), " + exportedEntries + " Eintrag/Einträge in faqs.yml gespeichert."); + return new int[]{exportedCats, exportedEntries}; + + } catch (Exception e) { + plugin.getLogger().severe("[FaqManager] Fehler bei Rückmigration: " + e.getMessage()); + return null; + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // HILFSMETHODEN + // ═══════════════════════════════════════════════════════════════════════ private String normalizeCategoryKey(String key) { if (key == null || key.isBlank()) return UNCATEGORIZED_KEY; diff --git a/src/main/java/de/ticketsystem/web/SessionManager.java b/src/main/java/de/ticketsystem/web/SessionManager.java index bd4ce1c..ff5d8cf 100644 --- a/src/main/java/de/ticketsystem/web/SessionManager.java +++ b/src/main/java/de/ticketsystem/web/SessionManager.java @@ -36,6 +36,21 @@ public class SessionManager { private final Map sessions = new ConcurrentHashMap<>(); private final SecureRandom random = new SecureRandom(); + // ── Rate-Limiting: fehlgeschlagene Login-Versuche pro IP ────────────── + /** Anzahl fehlgeschlagener Versuche pro IP */ + private final Map failedAttempts = new ConcurrentHashMap<>(); + /** Zeitpunkt der letzten fehlgeschlagenen Anfrage pro IP */ + private final Map lastFailTime = new ConcurrentHashMap<>(); + /** Zeitpunkt bis zu dem eine IP gesperrt ist (Epoch-ms) */ + private final Map blockedUntil = new ConcurrentHashMap<>(); + + /** Maximale Fehlversuche vor Sperre */ + private static final int MAX_ATTEMPTS = 5; + /** Zeitfenster für Fehlversuche in ms (10 Minuten) */ + private static final long ATTEMPT_WINDOW = 10 * 60 * 1000L; + /** Sperrdauer nach zu vielen Fehlversuchen in ms (15 Minuten) */ + private static final long BLOCK_DURATION = 15 * 60 * 1000L; + public SessionManager(TicketPlugin plugin) { this.plugin = plugin; migratePlaintextPasswords(); @@ -45,22 +60,56 @@ public class SessionManager { /** * Prüft Benutzername + Passwort und erstellt bei Erfolg eine Session. + * Blockiert IPs nach zu vielen Fehlversuchen. * - * @return Session-Token oder null bei falschem Login + * @param username Benutzername + * @param password Passwort (Klartext) + * @param clientIp IP-Adresse des Clients (für Rate-Limiting) + * @return Session-Token oder null bei falschem Login / gesperrter IP */ - public String login(String username, String password) { + public String login(String username, String password, String clientIp) { if (username == null || password == null) return null; + // ── IP-Sperre prüfen ────────────────────────────────────────────── + if (clientIp != null) { + Long blocked = blockedUntil.get(clientIp); + if (blocked != null && System.currentTimeMillis() < blocked) { + if (plugin.isDebug()) + plugin.getLogger().info("[WebPanel] Login-Versuch von gesperrter IP: " + clientIp); + return null; // IP gesperrt + } + } + ConfigurationSection users = plugin.getConfig().getConfigurationSection("web-panel.users"); if (users == null) return null; - ConfigurationSection user = users.getConfigurationSection(username.toLowerCase()); - if (user == null) return null; + // Case-insensitiver Lookup + String matchedKey = null; + for (String key : users.getKeys(false)) { + if (key.equalsIgnoreCase(username)) { matchedKey = key; break; } + } + if (matchedKey == null) { + recordFailedAttempt(clientIp); + return null; + } + + ConfigurationSection user = users.getConfigurationSection(matchedKey); + if (user == null) { recordFailedAttempt(clientIp); return null; } String storedHash = user.getString("password-hash", ""); String inputHash = sha256(password); - if (!storedHash.equalsIgnoreCase(inputHash)) return null; + if (!storedHash.equalsIgnoreCase(inputHash)) { + recordFailedAttempt(clientIp); + return null; + } + + // Login erfolgreich → Fehlversuche zurücksetzen + if (clientIp != null) { + failedAttempts.remove(clientIp); + lastFailTime.remove(clientIp); + blockedUntil.remove(clientIp); + } String roleStr = user.getString("role", "supporter").toUpperCase(); WebSession.Role role; @@ -72,16 +121,81 @@ public class SessionManager { long timeoutMs = plugin.getConfig().getLong("web-panel.session-timeout-minutes", 60) * 60_000L; String token = generateToken(); - WebSession session = new WebSession(token, username.toLowerCase(), role, timeoutMs); + WebSession session = new WebSession(token, matchedKey, role, timeoutMs); sessions.put(token, session); - if (plugin.isDebug()) { - plugin.getLogger().info("[WebPanel] Login: " + username + " (" + role + ")"); - } + if (plugin.isDebug()) + plugin.getLogger().info("[WebPanel] Login: " + matchedKey + " (" + role + ")" + + (clientIp != null ? " von " + clientIp : "")); return token; } + /** + * Rückwärtskompatible login()-Variante ohne IP (kein Rate-Limiting). + */ + public String login(String username, String password) { + return login(username, password, null); + } + + // ── Rate-Limiting Hilfsmethoden ─────────────────────────────────────── + + private void recordFailedAttempt(String ip) { + if (ip == null) return; + long now = System.currentTimeMillis(); + // Zeitfenster zurücksetzen wenn letzte Anfrage zu lange her + Long last = lastFailTime.get(ip); + if (last != null && now - last > ATTEMPT_WINDOW) { + failedAttempts.remove(ip); + } + lastFailTime.put(ip, now); + int attempts = failedAttempts.merge(ip, 1, Integer::sum); + + if (attempts >= MAX_ATTEMPTS) { + blockedUntil.put(ip, now + BLOCK_DURATION); + failedAttempts.remove(ip); + plugin.getLogger().warning("[WebPanel] IP " + ip + " nach " + MAX_ATTEMPTS + + " Fehlversuchen für " + (BLOCK_DURATION / 60000) + " Minuten gesperrt."); + } else if (plugin.isDebug()) { + plugin.getLogger().info("[WebPanel] Fehlversuch " + attempts + "/" + MAX_ATTEMPTS + + " von IP: " + ip); + } + } + + /** + * Gibt zurück ob eine IP aktuell gesperrt ist. + */ + public boolean isBlocked(String ip) { + if (ip == null) return false; + Long blocked = blockedUntil.get(ip); + if (blocked == null) return false; + if (System.currentTimeMillis() >= blocked) { + blockedUntil.remove(ip); + return false; + } + return true; + } + + /** + * Gibt die verbleibende Sperrdauer in Sekunden zurück (0 wenn nicht gesperrt). + */ + public long getBlockedSeconds(String ip) { + if (ip == null) return 0; + Long blocked = blockedUntil.get(ip); + if (blocked == null) return 0; + long rem = (blocked - System.currentTimeMillis()) / 1000; + return Math.max(0, rem); + } + + /** + * Entsperrt eine IP manuell. + */ + public void unblock(String ip) { + blockedUntil.remove(ip); + failedAttempts.remove(ip); + lastFailTime.remove(ip); + } + /** * Gibt die Session für ein Token zurück, oder null wenn ungültig/abgelaufen. * Erneuert den lastAccess-Zeitstempel bei gültiger Session. @@ -106,13 +220,27 @@ public class SessionManager { } /** - * Entfernt alle abgelaufenen Sessions (periodisch aufrufen). + * Entfernt alle abgelaufenen Sessions und abgelaufenen IP-Sperren (periodisch aufrufen). */ public void evictExpired() { Iterator> it = sessions.entrySet().iterator(); while (it.hasNext()) { if (it.next().getValue().isExpired()) it.remove(); } + // Abgelaufene IP-Sperren bereinigen + long now = System.currentTimeMillis(); + blockedUntil.entrySet().removeIf(e -> e.getValue() <= now); + // Fehlversuche außerhalb des Zeitfensters bereinigen + lastFailTime.entrySet().removeIf(e -> now - e.getValue() > ATTEMPT_WINDOW); + } + + /** + * Meldet alle aktiven Sessions eines Benutzers ab. + * Wird aufgerufen wenn ein Passwort geändert oder Benutzer gelöscht wird. + */ + public void invalidateUser(String username) { + sessions.entrySet().removeIf(e -> + e.getValue().getUsername().equalsIgnoreCase(username)); } public int activeSessionCount() { return sessions.size(); } diff --git a/src/main/java/de/ticketsystem/web/WebServer.java b/src/main/java/de/ticketsystem/web/WebServer.java index e80f3d1..3392626 100644 --- a/src/main/java/de/ticketsystem/web/WebServer.java +++ b/src/main/java/de/ticketsystem/web/WebServer.java @@ -1,21 +1,43 @@ package de.ticketsystem.web; import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpsServer; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; import de.ticketsystem.TicketPlugin; import de.ticketsystem.web.handlers.*; +import javax.net.ssl.*; +import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.net.InetSocketAddress; +import java.security.KeyStore; import java.util.concurrent.Executors; /** - * Startet und verwaltet den eingebetteten HTTP-Server für das Web-Panel. + * Startet und verwaltet den eingebetteten HTTP/HTTPS-Server für das Web-Panel. * * Konfiguration in config.yml: * web-panel: * enabled: true * port: 8085 - * bind-address: "0.0.0.0" # optional, Standard: alle Interfaces + * bind-address: "0.0.0.0" + * # HTTPS (optional): + * ssl: + * enabled: false + * keystore-file: "keystore.jks" # im plugins/TicketSystem/-Ordner ablegen + * keystore-password: "changeit" + * key-password: "changeit" # oft identisch mit keystore-password + * + * HTTPS-Setup (Self-Signed für Test): + * keytool -genkeypair -alias ticketsystem -keyalg RSA -keysize 2048 + * -validity 365 -keystore keystore.jks -storepass changeit + * + * Für Produktion: Let's Encrypt Zertifikat per certbot holen, dann: + * openssl pkcs12 -export -in fullchain.pem -inkey privkey.pem -out cert.p12 + * keytool -importkeystore -srckeystore cert.p12 -srcstoretype PKCS12 + * -destkeystore keystore.jks -deststoretype JKS */ public class WebServer { @@ -31,21 +53,31 @@ public class WebServer { public void start() { int port = plugin.getConfig().getInt("web-panel.port", 8085); String bindStr = plugin.getConfig().getString("web-panel.bind-address", "0.0.0.0"); + boolean sslEnabled = plugin.getConfig().getBoolean("web-panel.ssl.enabled", false); try { InetSocketAddress addr = new InetSocketAddress(bindStr, port); - server = HttpServer.create(addr, 0); - // Thread-Pool: max 4 Threads für Web-Requests + if (sslEnabled) { + server = buildHttpsServer(addr); + if (server == null) { + plugin.getLogger().warning("[WebPanel] HTTPS-Initialisierung fehlgeschlagen – starte ohne TLS."); + server = HttpServer.create(addr, 0); + } + } else { + server = HttpServer.create(addr, 0); + } + + // Thread-Pool server.setExecutor(Executors.newFixedThreadPool(4)); // ── Routes registrieren ────────────────────────────────────── - StaticHandler staticHandler = new StaticHandler(plugin); - LoginHandler loginHandler = new LoginHandler(plugin, sessionManager); - DashboardHandler dashHandler = new DashboardHandler(plugin, sessionManager); - TicketsHandler ticketsHandler = new TicketsHandler(plugin, sessionManager); - FaqHandler faqHandler = new FaqHandler(plugin, sessionManager); - ApiHandler apiHandler = new ApiHandler(plugin, sessionManager); + StaticHandler staticHandler = new StaticHandler(plugin); + LoginHandler loginHandler = new LoginHandler(plugin, sessionManager); + DashboardHandler dashHandler = new DashboardHandler(plugin, sessionManager); + TicketsHandler ticketsHandler = new TicketsHandler(plugin, sessionManager); + FaqHandler faqHandler = new FaqHandler(plugin, sessionManager); + ApiHandler apiHandler = new ApiHandler(plugin, sessionManager); server.createContext("/", loginHandler); server.createContext("/login", loginHandler); @@ -59,21 +91,82 @@ public class WebServer { server.start(); - plugin.getLogger().info("[WebPanel] HTTP-Server gestartet auf " + bindStr + ":" + port); + String protocol = sslEnabled && server instanceof HttpsServer ? "HTTPS" : "HTTP"; + plugin.getLogger().info("[WebPanel] " + protocol + "-Server gestartet auf " + + bindStr + ":" + port); + if (!sslEnabled) { + plugin.getLogger().info("[WebPanel] Tipp: Für HTTPS 'web-panel.ssl.enabled: true' setzen und keystore.jks bereitstellen."); + } // Session-Cleanup alle 10 Minuten plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, () -> sessionManager.evictExpired(), 12000L, 12000L); } catch (IOException e) { - plugin.getLogger().severe("[WebPanel] Konnte HTTP-Server nicht starten: " + e.getMessage()); + plugin.getLogger().severe("[WebPanel] Konnte Server nicht starten: " + e.getMessage()); + } + } + + /** + * Baut einen HttpsServer mit dem konfigurierten JKS-Keystore. + * Gibt null zurück wenn Konfiguration fehlt oder Keystore nicht geladen werden kann. + */ + private HttpsServer buildHttpsServer(InetSocketAddress addr) { + String keystoreFile = plugin.getConfig().getString("web-panel.ssl.keystore-file", "keystore.jks"); + String keystorePass = plugin.getConfig().getString("web-panel.ssl.keystore-password","changeit"); + String keyPass = plugin.getConfig().getString("web-panel.ssl.key-password", keystorePass); + + File ksFile = new File(plugin.getDataFolder(), keystoreFile); + if (!ksFile.exists()) { + plugin.getLogger().severe("[WebPanel/SSL] Keystore nicht gefunden: " + ksFile.getAbsolutePath()); + plugin.getLogger().info("[WebPanel/SSL] Self-Signed erstellen mit:"); + plugin.getLogger().info(" keytool -genkeypair -alias ticketsystem -keyalg RSA -keysize 2048 -validity 365 -keystore " + ksFile.getAbsolutePath() + " -storepass changeit"); + return null; + } + + try { + KeyStore ks = KeyStore.getInstance("JKS"); + try (FileInputStream fis = new FileInputStream(ksFile)) { + ks.load(fis, keystorePass.toCharArray()); + } + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, keyPass.toCharArray()); + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + + SSLContext sslCtx = SSLContext.getInstance("TLS"); + sslCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); + + HttpsServer httpsServer = HttpsServer.create(addr, 0); + httpsServer.setHttpsConfigurator(new HttpsConfigurator(sslCtx) { + @Override + public void configure(HttpsParameters params) { + SSLContext ctx = getSSLContext(); + SSLParameters sslParams = ctx.getDefaultSSLParameters(); + // Nur TLS 1.2+ zulassen + sslParams.setProtocols(new String[]{"TLSv1.2", "TLSv1.3"}); + params.setSSLParameters(sslParams); + } + }); + + plugin.getLogger().info("[WebPanel/SSL] TLS-Konfiguration geladen: " + ksFile.getName()); + return httpsServer; + + } catch (Exception e) { + plugin.getLogger().severe("[WebPanel/SSL] Fehler beim Laden des Keystores: " + e.getMessage()); + return null; } } public void stop() { if (server != null) { server.stop(1); - plugin.getLogger().info("[WebPanel] HTTP-Server gestoppt."); + plugin.getLogger().info("[WebPanel] Server gestoppt."); } } + + public boolean isRunning() { return server != null; } + public boolean isHttps() { return server instanceof HttpsServer; } } \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/web/WebSession.java b/src/main/java/de/ticketsystem/web/WebSession.java index ba5b874..af8570c 100644 --- a/src/main/java/de/ticketsystem/web/WebSession.java +++ b/src/main/java/de/ticketsystem/web/WebSession.java @@ -6,8 +6,9 @@ package de.ticketsystem.web; public class WebSession { public enum Role { - ADMIN, // Voller Zugriff: Tickets, FAQ, Stats, Blacklist, Reload - SUPPORTER // Tickets anzeigen, claimen, schließen, kommentieren + ADMIN, // Voller Zugriff: Tickets, FAQ, Stats, Blacklist, Reload + SUPPORTER, // Tickets anzeigen, claimen, schließen, kommentieren + ARCHIVE_VIEWER // Wie SUPPORTER + Zugriff auf das Archiv } private final String token; @@ -32,8 +33,14 @@ public class WebSession { this.lastAccess = System.currentTimeMillis(); } - public String getToken() { return token; } - public String getUsername() { return username; } - public Role getRole() { return role; } - public boolean isAdmin() { return role == Role.ADMIN; } + public String getToken() { return token; } + public String getUsername() { return username; } + public Role getRole() { return role; } + public boolean isAdmin() { return role == Role.ADMIN; } + + /** + * Gibt true zurück wenn der Benutzer das Archiv einsehen darf. + * Admins und ARCHIVE_VIEWER haben Zugriff. + */ + public boolean canViewArchive() { return role == Role.ADMIN || role == Role.ARCHIVE_VIEWER; } } \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/web/handlers/ApiHandler.java b/src/main/java/de/ticketsystem/web/handlers/ApiHandler.java index d657913..3e062d9 100644 --- a/src/main/java/de/ticketsystem/web/handlers/ApiHandler.java +++ b/src/main/java/de/ticketsystem/web/handlers/ApiHandler.java @@ -60,12 +60,36 @@ public class ApiHandler extends BaseHandler implements HttpHandler { return; } + // ── Archiv-Aktionen ────────────────────────────────────────── + if (path.startsWith("/api/archive/")) { + handleArchiveApi(ex, session, path, method); + return; + } + // ── FAQ-Aktionen ───────────────────────────────────────────── if (path.startsWith("/api/faq")) { handleFaqApi(ex, session, path, method); return; } + // ── Benutzer-Verwaltung ────────────────────────────────────── + if (path.startsWith("/api/users")) { + handleUsersApi(ex, session, path, method); + return; + } + + // ── Backup ─────────────────────────────────────────────────── + if (path.equals("/api/backup") && method.equals("POST")) { + if (!session.isAdmin()) { sendJson(ex, 403, err("Kein Zugriff")); return; } + java.io.File backup = plugin.getDatabaseManager().createBackup(); + if (backup != null) { + sendJson(ex, 200, "{\"ok\":true,\"file\":\"" + jsonEsc(backup.getName()) + "\"}"); + } else { + sendJson(ex, 500, err("Backup fehlgeschlagen")); + } + return; + } + sendJson(ex, 404, "{\"ok\":false,\"error\":\"Unbekannter Endpunkt\"}"); } catch (Exception e) { @@ -287,6 +311,156 @@ public class ApiHandler extends BaseHandler implements HttpHandler { sendJson(ex, 404, err("Unbekannter FAQ-Endpunkt")); } + // ─────────────────────────── Archiv-API ──────────────────────────────── + + /** + * POST /api/archive/{id}/delete – Ticket permanent löschen (admin only) + * POST /api/archive/{id}/restore – Ticket wiederherstellen (admin only) + */ + private void handleArchiveApi(HttpExchange ex, WebSession session, String path, String method) throws IOException { + if (!session.canViewArchive()) { sendJson(ex, 403, err("Kein Zugriff")); return; } + if (!method.equals("POST")) { sendJson(ex, 405, err("Method not allowed")); return; } + + // /api/archive/{id}/{action} + String[] parts = path.split("/"); + if (parts.length < 5) { sendJson(ex, 400, err("Ungültiger Pfad")); return; } + + int ticketId; + try { ticketId = Integer.parseInt(parts[3]); } + catch (NumberFormatException e) { sendJson(ex, 400, err("Ungültige ID")); return; } + + String action = parts[4]; + var db = plugin.getDatabaseManager(); + + switch (action) { + case "delete" -> { + if (!session.isAdmin()) { sendJson(ex, 403, err("Nur Admins dürfen löschen")); return; } + boolean ok = db.deleteArchivedTicket(ticketId); + sendJson(ex, ok ? 200 : 404, ok ? "{\"ok\":true}" : err("Ticket nicht gefunden")); + } + case "restore" -> { + if (!session.isAdmin()) { sendJson(ex, 403, err("Nur Admins dürfen wiederherstellen")); return; } + boolean ok = db.restoreArchivedTicket(ticketId); + sendJson(ex, ok ? 200 : 500, ok ? "{\"ok\":true}" : err("Wiederherstellen fehlgeschlagen")); + } + default -> sendJson(ex, 404, err("Unbekannte Archiv-Aktion: " + action)); + } + } + + // ─────────────────────────── Benutzer-API ────────────────────────────── + + /** + * GET /api/users – Alle Benutzer auflisten (admin only) + * POST /api/users – Neuen Benutzer anlegen (admin only) + * body: {username, password, role} + * POST /api/users/{name}/password – Passwort ändern (admin only) + * body: {password} + * DELETE /api/users/{name} – Benutzer löschen (admin only) + */ + private void handleUsersApi(HttpExchange ex, WebSession session, String path, String method) throws IOException { + if (!session.isAdmin()) { sendJson(ex, 403, err("Kein Zugriff")); return; } + + // GET /api/users – Liste + if (path.equals("/api/users") && method.equals("GET")) { + org.bukkit.configuration.ConfigurationSection users = + plugin.getConfig().getConfigurationSection("web-panel.users"); + StringBuilder json = new StringBuilder("["); + if (users != null) { + boolean first = true; + for (String key : users.getKeys(false)) { + if (!first) json.append(","); + first = false; + String role = plugin.getConfig().getString("web-panel.users." + key + ".role", "supporter"); + json.append("{\"username\":\"").append(jsonEsc(key)) + .append("\",\"role\":\"").append(jsonEsc(role)).append("\"}"); + } + } + json.append("]"); + sendJson(ex, 200, json.toString()); + return; + } + + // POST /api/users – Anlegen + if (path.equals("/api/users") && method.equals("POST")) { + Map body = parseJsonBody(ex); + String username = body.getOrDefault("username", "").trim(); + String password = body.getOrDefault("password", "").trim(); + String role = body.getOrDefault("role", "supporter").trim().toLowerCase(); + + if (username.isEmpty() || password.isEmpty()) { + sendJson(ex, 400, err("Benutzername und Passwort erforderlich")); return; + } + if (!role.equals("admin") && !role.equals("supporter") && !role.equals("archive_viewer")) { + sendJson(ex, 400, err("Ungültige Rolle (admin/supporter/archive_viewer)")); return; + } + // Prüfen ob Benutzer schon existiert + org.bukkit.configuration.ConfigurationSection existing = + plugin.getConfig().getConfigurationSection("web-panel.users"); + if (existing != null) { + for (String k : existing.getKeys(false)) { + if (k.equalsIgnoreCase(username)) { + sendJson(ex, 409, err("Benutzer existiert bereits")); return; + } + } + } + String hash = de.ticketsystem.web.SessionManager.sha256(password); + plugin.getConfig().set("web-panel.users." + username + ".password-hash", hash); + plugin.getConfig().set("web-panel.users." + username + ".role", role); + plugin.saveConfig(); + sendJson(ex, 200, "{\"ok\":true}"); + return; + } + + // POST /api/users/{name}/password – Passwort ändern + if (path.matches("/api/users/[^/]+/password") && method.equals("POST")) { + String targetUser = path.split("/")[3]; + Map body = parseJsonBody(ex); + String newPass = body.getOrDefault("password", "").trim(); + if (newPass.isEmpty()) { sendJson(ex, 400, err("Passwort darf nicht leer sein")); return; } + + String cfgPath = findUserConfigPath(targetUser); + if (cfgPath == null) { sendJson(ex, 404, err("Benutzer nicht gefunden")); return; } + + String hash = de.ticketsystem.web.SessionManager.sha256(newPass); + plugin.getConfig().set(cfgPath + ".password-hash", hash); + plugin.getConfig().set(cfgPath + ".password", null); + plugin.saveConfig(); + // Alle Sessions dieses Benutzers invalidieren + sessionManager.invalidateUser(targetUser); + sendJson(ex, 200, "{\"ok\":true}"); + return; + } + + // DELETE /api/users/{name} – Löschen + if (path.matches("/api/users/[^/]+") && method.equals("DELETE")) { + String targetUser = path.split("/")[3]; + // Darf sich nicht selbst löschen + if (targetUser.equalsIgnoreCase(session.getUsername())) { + sendJson(ex, 400, err("Du kannst dich nicht selbst löschen")); return; + } + String cfgPath = findUserConfigPath(targetUser); + if (cfgPath == null) { sendJson(ex, 404, err("Benutzer nicht gefunden")); return; } + plugin.getConfig().set(cfgPath, null); + plugin.saveConfig(); + sessionManager.invalidateUser(targetUser); + sendJson(ex, 200, "{\"ok\":true}"); + return; + } + + sendJson(ex, 404, err("Unbekannter Benutzer-Endpunkt")); + } + + /** Sucht den config.yml-Pfad eines Benutzers (case-insensitiv). */ + private String findUserConfigPath(String username) { + org.bukkit.configuration.ConfigurationSection users = + plugin.getConfig().getConfigurationSection("web-panel.users"); + if (users == null) return null; + for (String key : users.getKeys(false)) { + if (key.equalsIgnoreCase(username)) return "web-panel.users." + key; + } + return null; + } + // ─────────────────────────── Hilfsmethoden ───────────────────────────── /** diff --git a/src/main/java/de/ticketsystem/web/handlers/BaseHandler.java b/src/main/java/de/ticketsystem/web/handlers/BaseHandler.java index 804990e..e697347 100644 --- a/src/main/java/de/ticketsystem/web/handlers/BaseHandler.java +++ b/src/main/java/de/ticketsystem/web/handlers/BaseHandler.java @@ -322,6 +322,15 @@ public abstract class BaseHandler { """.formatted(title, title, message, wl(plugin, "btn-back")); } + protected String getClientIp(HttpExchange ex) { + // X-Forwarded-For wenn hinter Reverse-Proxy + String forwarded = ex.getRequestHeaders().getFirst("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return ex.getRemoteAddress().getAddress().getHostAddress(); + } + protected String escHtml(String s) { if (s == null) return ""; return s.replace("&", "&") diff --git a/src/main/java/de/ticketsystem/web/handlers/TicketsHandler.java b/src/main/java/de/ticketsystem/web/handlers/TicketsHandler.java index bc5595b..7668000 100644 --- a/src/main/java/de/ticketsystem/web/handlers/TicketsHandler.java +++ b/src/main/java/de/ticketsystem/web/handlers/TicketsHandler.java @@ -60,11 +60,26 @@ public class TicketsHandler extends BaseHandler implements HttpHandler { int page = parseInt(params.getOrDefault("page", "1"), 1); DatabaseManager db = plugin.getDatabaseManager(); - List all = db.getAllTickets(); + List all; + + // Archiv-Ansicht: nur für berechtigte Benutzer + if ("ARCHIVED".equalsIgnoreCase(filterStatus)) { + if (!session.canViewArchive()) { + sendHtml(ex, 403, errorPage( + wl(plugin, "error-403-title"), + wl(plugin, "error-403-message"), + plugin)); + return; + } + all = db.getArchivedTickets(); + } else { + all = db.getAllTickets(); + } // ── Filter ── List filtered = all.stream() - .filter(t -> filterStatus.equals("all") || t.getStatus().name().equalsIgnoreCase(filterStatus)) + .filter(t -> filterStatus.equals("all") || filterStatus.equalsIgnoreCase("ARCHIVED") + || t.getStatus().name().equalsIgnoreCase(filterStatus)) .filter(t -> filterCat.equals("all") || t.getCategoryKey().equalsIgnoreCase(filterCat)) .filter(t -> filterPrio.equals("all") || t.getPriority().name().equalsIgnoreCase(filterPrio)) .filter(t -> filterSearch.isEmpty() @@ -83,27 +98,38 @@ public class TicketsHandler extends BaseHandler implements HttpHandler { List pageTickets = filtered.subList(fromIdx, toIdx); String content = buildList(pageTickets, total, page, totalPages, - filterStatus, filterCat, filterPrio, filterSearch); + filterStatus, filterCat, filterPrio, filterSearch, session); sendHtml(ex, 200, layout(wl(plugin, "tickets-title"), content, session, plugin)); } private String buildList(List tickets, int total, int page, int totalPages, - String filterStatus, String filterCat, String filterPrio, String filterSearch) { + String filterStatus, String filterCat, String filterPrio, String filterSearch, + WebSession session) { StringBuilder sb = new StringBuilder(); - sb.append("

") + sb.append("
"); + sb.append("

") .append(escHtml(wl(plugin, "tickets-title"))) .append(" ").append(total).append(" ") .append(escHtml(wl(plugin, "tickets-total"))) .append("

"); + if (session.canViewArchive()) { + sb.append("") + .append("🗃 ").append(escHtml(wl(plugin, "nav-archive"))).append(""); + } + sb.append("
"); // ── Filter-Bar ── sb.append("
"); - sb.append(selectFilter("status", filterStatus, List.of( + List> statusOpts = new ArrayList<>(List.of( entry("all", wl(plugin, "filter-all-status")), entry("OPEN", wl(plugin, "filter-open")), entry("CLAIMED", wl(plugin, "filter-claimed")), entry("FORWARDED", wl(plugin, "filter-forwarded")), - entry("CLOSED", wl(plugin, "filter-closed"))))); + entry("CLOSED", wl(plugin, "filter-closed")))); + if (session.canViewArchive()) { + statusOpts.add(entry("ARCHIVED", wl(plugin, "filter-archived"))); + } + sb.append(selectFilter("status", filterStatus, statusOpts)); if (plugin.getConfig().getBoolean("categories-enabled", true)) { List> catOpts = new ArrayList<>(); @@ -194,6 +220,16 @@ public class TicketsHandler extends BaseHandler implements HttpHandler { private void handleDetail(HttpExchange ex, WebSession session, int ticketId) throws IOException { Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId); + boolean fromArchive = false; + + // Nicht in aktiver DB? → im Archiv suchen (nur wenn berechtigt) + if (ticket == null && session.canViewArchive()) { + ticket = plugin.getDatabaseManager().getArchivedTickets().stream() + .filter(t -> t.getId() == ticketId) + .findFirst().orElse(null); + if (ticket != null) fromArchive = true; + } + if (ticket == null) { sendHtml(ex, 404, errorPage( wl(plugin, "detail-not-found"), @@ -202,12 +238,18 @@ public class TicketsHandler extends BaseHandler implements HttpHandler { return; } - List comments = plugin.getDatabaseManager().getComments(ticketId); - String content = buildDetail(ticket, comments, session); + List comments = fromArchive + ? List.of() + : plugin.getDatabaseManager().getComments(ticketId); + String content = buildDetail(ticket, comments, session, fromArchive); sendHtml(ex, 200, layout("Ticket #" + ticketId, content, session, plugin)); } private String buildDetail(Ticket t, List comments, WebSession session) { + return buildDetail(t, comments, session, false); + } + + private String buildDetail(Ticket t, List comments, WebSession session, boolean fromArchive) { StringBuilder sb = new StringBuilder(); String created = t.getCreatedAt() != null ? SDF.format(t.getCreatedAt()) : "—"; String catName = getCategoryName(t); @@ -257,7 +299,7 @@ public class TicketsHandler extends BaseHandler implements HttpHandler { } // ── Aktionen (nur bei aktiven Tickets) ── - if (t.getStatus() != TicketStatus.CLOSED) { + if (t.getStatus() != TicketStatus.CLOSED && !fromArchive) { sb.append("
") .append(escHtml(wl(plugin, "detail-section-actions"))) .append("
"); @@ -302,6 +344,18 @@ public class TicketsHandler extends BaseHandler implements HttpHandler { sb.append("
"); } + // ── Archiv-Aktionen (nur für archivierte Tickets) ── + if (fromArchive && session.isAdmin()) { + sb.append("
") + .append(escHtml(wl(plugin, "detail-section-actions"))) + .append("
"); + sb.append(""); + sb.append(""); + sb.append("
"); + } + // ── Kommentare ── sb.append("
") .append(escHtml(wl(plugin, "detail-section-comments"))) diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 1e7ddf2..ba07e3d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -14,7 +14,7 @@ # --- GRUNDLEGEND --- # Version der Konfigurationsdatei. Nicht ändern! -version: "2.4" +version: "2.5" # ---------------------------------------------------- # SPRACHE / LANGUAGE @@ -307,6 +307,24 @@ web-panel: enabled: false port: 8085 + # -- HTTPS / SSL (Optional) ----------------------------------------- + # Aktiviere HTTPS fuer verschluesselte Verbindungen. + # Voraussetzung: Java Keystore (.jks) im Plugin-Ordner. + # + # Self-Signed Zertifikat erstellen (Test): + # keytool -genkeypair -alias ticketsystem -keyalg RSA -keysize 2048 + # -validity 365 -keystore plugins/TicketSystem/keystore.jks + # -storepass changeit + # + # Produktiv: Lets Encrypt Zertifikat -> PKCS12 -> JKS konvertieren. + # Anleitung: WebServer.java Javadoc + # ------------------------------------------------------------------- + ssl: + enabled: false + keystore-file: "keystore.jks" # Datei im plugins/TicketSystem/-Ordner + keystore-password: "changeit" + key-password: "changeit" # oft identisch mit keystore-password + # Bind-Adresse: # "0.0.0.0" = alle Interfaces (direkt erreichbar) # "127.0.0.1" = nur lokal (Reverse-Proxy empfohlen) @@ -321,8 +339,13 @@ web-panel: # ── Benutzer ────────────────────────────────────────────── # Rollen: - # admin → Voller Zugriff: Tickets, FAQ, Weiterleiten - # supporter → Tickets anzeigen, claimen, schließen, kommentieren + # admin → Voller Zugriff: Tickets, FAQ, Weiterleiten, Archiv + # supporter → Tickets anzeigen, claimen, schließen, kommentieren + # archive_viewer → Wie supporter + Zugriff auf das Archiv + # + # Benutzernamen: + # - Groß-/Kleinschreibung wird beim Login ignoriert (case-insensitiv) + # - Sonderzeichen erlaubt (z.B. Viper-Admin, support_01, max/muster) # # Passwort setzen: # Trage "password: deinPasswort" ein. @@ -335,4 +358,8 @@ web-panel: role: "admin" supporter: password: "aendere_mich" - role: "supporter" \ No newline at end of file + role: "supporter" + # Beispiel: Supporter mit Archiv-Zugriff + # archiv-viewer: + # password: "deinPasswort" + # role: "archive_viewer" \ No newline at end of file diff --git a/src/main/resources/lang_de.yml b/src/main/resources/lang_de.yml index 9998798..296aa2d 100644 --- a/src/main/resources/lang_de.yml +++ b/src/main/resources/lang_de.yml @@ -46,6 +46,14 @@ system: file-not-found: "&cDatei nicht gefunden: &e{file}" unknown-mode: "&cUnbekannter Modus! Benutze: tomysql oder tofile" validation-warning: "&cEs wurden &e{count} &cungültige Tickets beim Laden gefunden." + +# ============================================================ +# BACKUP +# ============================================================ +backup: + start: "&eBackup wird erstellt..." + success: "&aBackup erfolgreich erstellt: &e{file}" + fail: "&cBackup fehlgeschlagen. Prüfe die Konsole." db-create-error: "&cFehler beim Erstellen des Tickets!" # ============================================================ @@ -242,6 +250,12 @@ faq: deleted: "&aFAQ &e#{id} &awurde gelöscht." not-found: "&cFAQ &e#{id} &cwurde nicht gefunden." reloaded: "&aFAQs wurden neu geladen. ({count} Einträge)" + migrate-no-mysql: "&cFAQ-Migration nur möglich wenn use-mysql: true aktiv ist." + migrate-start: "&eFAQ-Migration gestartet... (faqs.yml → MySQL)" + migrate-success: "&aFAQ-Migration abgeschlossen! &7{cats} Kategorie(n) und {entries} Eintrag/Einträge importiert." + migrate-fail: "&cFAQ-Migration fehlgeschlagen. Prüfe die Konsole für Details." + migrate-tofile-start: "&eFAQ-Export gestartet... (MySQL → faqs.yml)" + migrate-tofile-success: "&aFAQ-Export abgeschlossen! &7{cats} Kategorie(n) und {entries} Einträge gespeichert." list-header: "&6Häufige Fragen (FAQ) &7— {count} Einträge" list-empty: "&7Noch keine FAQs vorhanden." list-entry: "&e#{id} &f{question}" @@ -249,7 +263,7 @@ faq: list-admin-hint: "&7Befehle: &e{cmd_faq} add &8| &e{cmd_faq} edit &8| &e{cmd_faq} delete " unknown-sub: "&cUnbekannter FAQ-Befehl." hint-open: "&7Benutze &e{cmd_faq} &7zum Öffnen der GUI." - admin-commands: "&7Admin-Befehle: &e{cmd_faq} add | edit | delete | reload | list" + admin-commands: "&7Admin-Befehle: &e{cmd_faq} add | edit | delete | reload | list | migrate" # ============================================================ # FAQ-KATEGORIEN BEFEHL (/ticket kategorie) @@ -559,6 +573,9 @@ web: login-heading: "Willkommen zurück" login-sub: "Melde dich mit deinem Account an, um fortzufahren." login-error: "Benutzername oder Passwort falsch." + login-blocked: "Zu viele Fehlversuche. Bitte warte {seconds} Sekunden." + archive-btn-restore: "Wiederherstellen" + archive-btn-delete: "Permanent löschen" login-label-user: "Benutzername" login-label-pass: "Passwort" login-btn: "Anmelden" diff --git a/src/main/resources/lang_en.yml b/src/main/resources/lang_en.yml index b4774f5..b171cd9 100644 --- a/src/main/resources/lang_en.yml +++ b/src/main/resources/lang_en.yml @@ -46,6 +46,14 @@ system: file-not-found: "&cFile not found: &e{file}" unknown-mode: "&cUnknown mode! Use: tomysql or tofile" validation-warning: "&c&e{count} &cinvalid tickets were found during loading." + +# ============================================================ +# BACKUP +# ============================================================ +backup: + start: "&eCreating backup..." + success: "&aBackup created successfully: &e{file}" + fail: "&cBackup failed. Check the console." db-create-error: "&cFailed to create the ticket!" # ============================================================ @@ -242,6 +250,12 @@ faq: deleted: "&aFAQ &e#{id} &ahas been deleted." not-found: "&cFAQ &e#{id} &cwas not found." reloaded: "&aFAQs reloaded. ({count} entries)" + migrate-no-mysql: "&cFAQ migration only possible when use-mysql: true is active." + migrate-start: "&eFAQ migration started... (faqs.yml → MySQL)" + migrate-success: "&aFAQ migration complete! &7{cats} category/categories and {entries} entry/entries imported." + migrate-fail: "&cFAQ migration failed. Check the console for details." + migrate-tofile-start: "&eFAQ export started... (MySQL → faqs.yml)" + migrate-tofile-success: "&aFAQ export complete! &7{cats} category/categories and {entries} entry/entries saved to faqs.yml." list-header: "&6Frequently Asked Questions (FAQ) &7— {count} entries" list-empty: "&7No FAQs available yet." list-entry: "&e#{id} &f{question}" @@ -249,7 +263,7 @@ faq: list-admin-hint: "&7Commands: &e{cmd_faq} add &8| &e{cmd_faq} edit &8| &e{cmd_faq} delete " unknown-sub: "&cUnknown FAQ subcommand." hint-open: "&7Use &e{cmd_faq} &7to open the GUI." - admin-commands: "&7Admin commands: &e{cmd_faq} add | edit | delete | reload | list" + admin-commands: "&7Admin commands: &e{cmd_faq} add | edit | delete | reload | list | migrate" # ============================================================ # FAQ CATEGORY COMMAND (/ticket category) @@ -558,7 +572,10 @@ web: login-title: "Login – TicketSystem Panel" login-heading: "Welcome back" login-sub: "Sign in with your account to continue." + login-blocked: "Too many failed attempts. Please wait {seconds} seconds." login-error: "Username or password incorrect." + archive-btn-restore: "Restore" + archive-btn-delete: "Delete permanently" login-label-user: "Username" login-label-pass: "Password" login-btn: "Sign In"