Upload folder via GUI - src

This commit is contained in:
Git Manager GUI
2026-04-16 11:48:01 +02:00
parent 5e102ec4e1
commit 084172116d
13 changed files with 1377 additions and 142 deletions

View File

@@ -95,7 +95,7 @@ public class TicketPlugin extends JavaPlugin {
// Versionsprüfung der config.yml // Versionsprüfung der config.yml
String configVersion = getConfig().getString("version", ""); String configVersion = getConfig().getString("version", "");
String expectedVersion = "2.4"; String expectedVersion = "2.5";
if (!expectedVersion.equals(configVersion)) { if (!expectedVersion.equals(configVersion)) {
getLogger().warning("[WARNUNG] config.yml-Version (" + configVersion getLogger().warning("[WARNUNG] config.yml-Version (" + configVersion
+ ") stimmt nicht mit der erwarteten Version (" + expectedVersion + ") überein!"); + ") stimmt nicht mit der erwarteten Version (" + expectedVersion + ") überein!");

View File

@@ -52,6 +52,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
case "importieren" -> "import"; case "importieren" -> "import";
case "statistik" -> "stats"; case "statistik" -> "stats";
case "archivieren" -> "archive"; case "archivieren" -> "archive";
case "sichern" -> "backup";
case "kommentar" -> "comment"; case "kommentar" -> "comment";
case "sperrliste" -> "blacklist"; case "sperrliste" -> "blacklist";
case "bewerten" -> "rate"; case "bewerten" -> "rate";
@@ -90,6 +91,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
case "stats" -> handleStats(player); case "stats" -> handleStats(player);
case "top" -> handleTop(player); case "top" -> handleTop(player);
case "archive" -> handleArchive(player); case "archive" -> handleArchive(player);
case "backup" -> handleBackup(player);
case "comment" -> handleComment(player, args); case "comment" -> handleComment(player, args);
case "blacklist" -> handleBlacklist(player, args); case "blacklist" -> handleBlacklist(player, args);
case "rate" -> handleRate(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())); 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 ─────────────────────────────────────────────────── // ── /ticket archive ───────────────────────────────────────────────────
private void handleArchive(Player player) { private void handleArchive(Player player) {
@@ -835,6 +857,52 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
"{count}", String.valueOf(plugin.getFaqManager().getAll().size()))); "{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" -> { case "list", "liste" -> {
List<FaqEntry> all = plugin.getFaqManager().getAll(); List<FaqEntry> all = plugin.getFaqManager().getAll();
player.sendMessage(plugin.lang().get("general.separator")); player.sendMessage(plugin.lang().get("general.separator"));
@@ -1045,8 +1113,8 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
if (useEn) faqSubs.add("list"); if (useEn) faqSubs.add("list");
if (useDe) faqSubs.add("liste"); if (useDe) faqSubs.add("liste");
if (player.hasPermission("ticket.admin")) { if (player.hasPermission("ticket.admin")) {
if (useEn) faqSubs.addAll(List.of("add", "edit", "delete", "reload")); if (useEn) faqSubs.addAll(List.of("add", "edit", "delete", "reload", "migrate"));
if (useDe) faqSubs.addAll(List.of("hinzufügen", "bearbeiten", "löschen", "neuladen")); if (useDe) faqSubs.addAll(List.of("hinzufügen", "bearbeiten", "löschen", "neuladen", "migrieren"));
} }
for (String s : faqSubs) for (String s : faqSubs)
if (s.startsWith(args[1].toLowerCase())) completions.add(s); if (s.startsWith(args[1].toLowerCase())) completions.add(s);

View File

@@ -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 ──────────────────────── // ─────────────────────────── Tabellen erstellen ────────────────────────
@@ -257,6 +309,27 @@ public class DatabaseManager {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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. // Ausstehende BungeeCord-Teleport-Aufträge.
// Wird gesetzt wenn ein Admin via GUI/Command auf einen anderen Server teleportiert. // Wird gesetzt wenn ein Admin via GUI/Command auf einen anderen Server teleportiert.
// PlayerJoinListener liest den Eintrag beim Ankommen, teleportiert, löscht ihn dann. // PlayerJoinListener liest den Eintrag beim Ankommen, teleportiert, löscht ihn dann.
@@ -295,6 +368,8 @@ public class DatabaseManager {
stmt.execute(statsSql); stmt.execute(statsSql);
stmt.execute(pendingTeleportSql); stmt.execute(pendingTeleportSql);
stmt.execute(creatorStatsSql); stmt.execute(creatorStatsSql);
stmt.execute(faqEntriesSql);
stmt.execute(faqCategoriesSql);
} catch (SQLException e) { } catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "Fehler beim Erstellen der Tabellen: " + e.getMessage(), e); plugin.getLogger().log(Level.SEVERE, "Fehler beim Erstellen der Tabellen: " + e.getMessage(), e);
} }
@@ -1287,6 +1362,296 @@ public class DatabaseManager {
return removed; 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<de.ticketsystem.model.FaqEntry> getFaqEntries() {
List<de.ticketsystem.model.FaqEntry> 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<de.ticketsystem.model.FaqCategory> getFaqCategories() {
List<de.ticketsystem.model.FaqCategory> 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<Ticket> getArchivedTickets() {
List<Ticket> 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 ─────────────────────────────── // ─────────────────────────── Statistiken ───────────────────────────────
public TicketStats getTicketStats() { 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()); } catch (IOException e) { if (plugin != null) plugin.getLogger().severe("Fehler beim Speichern von " + dataFileName + ": " + e.getMessage()); }
} }
// ─────────────────────────── Backup (Platzhalter) ────────────────────── // ─────────────────────────── Backup ────────────────────────────────────
private void backupMySQL() {}
private void backupDataFile() {} /**
* 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<Ticket> 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();
}
} }

View File

@@ -1,6 +1,7 @@
package de.ticketsystem.manager; package de.ticketsystem.manager;
import de.ticketsystem.TicketPlugin; import de.ticketsystem.TicketPlugin;
import de.ticketsystem.database.DatabaseManager;
import de.ticketsystem.model.FaqCategory; import de.ticketsystem.model.FaqCategory;
import de.ticketsystem.model.FaqEntry; import de.ticketsystem.model.FaqEntry;
import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.ConfigurationSection;
@@ -11,54 +12,93 @@ import java.io.IOException;
import java.util.*; 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. * Speichermodus wird in config.yml festgelegt:
* Admins können Kategorien und FAQs direkt in-game verwalten (GUI + Befehle).
* *
* faqs.yml Layout: * faq-storage: file → faqs.yml (Standard, Einzelserver)
* faq-storage: mysql → MySQL-Datenbank (BungeeCord / Multi-Server, use-mysql muss true sein)
* *
* categories: * Im MySQL-Modus werden Tabellen faq_entries und faq_categories verwendet.
* tickets: * Im Datei-Modus verhält sich der Manager exakt wie bisher (faqs.yml).
* name: "Tickets"
* color: "&b"
* description: "Fragen zum Ticket-System"
* *
* faqs: * Die öffentliche API (add, edit, delete, getAll …) ist in beiden Modi identisch
* 1: * FaqGUI, FaqHandler und ApiHandler müssen nicht angepasst werden.
* 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.
*/ */
public class FaqManager { public class FaqManager {
public static final String UNCATEGORIZED_KEY = "__none__"; public static final String UNCATEGORIZED_KEY = "__none__";
private final TicketPlugin plugin; private final TicketPlugin plugin;
private final boolean useMysql;
// ── YAML-Modus ────────────────────────────────────────────────────────
private final File faqFile; private final File faqFile;
private YamlConfiguration faqConfig; private YamlConfiguration faqConfig;
// ── In-Memory-State (beide Modi) ──────────────────────────────────────
private final List<FaqEntry> entries = new ArrayList<>(); private final List<FaqEntry> entries = new ArrayList<>();
private final LinkedHashMap<String, FaqCategory> categories = new LinkedHashMap<>(); private final LinkedHashMap<String, FaqCategory> categories = new LinkedHashMap<>();
/** Nur im YAML-Modus relevant im MySQL-Modus liefert AUTO_INCREMENT die ID */
private int nextId = 1; private int nextId = 1;
public FaqManager(TicketPlugin plugin) { public FaqManager(TicketPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
this.faqFile = new File(plugin.getDataFolder(), "faqs.yml"); 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(); load();
} }
// ─────────────────────────── Loading & Saving ─────────────────────────── // ═══════════════════════════════════════════════════════════════════════
// LADEN & SPEICHERN
// ═══════════════════════════════════════════════════════════════════════
private void load() { private void load() {
entries.clear(); entries.clear();
categories.clear(); categories.clear();
nextId = 1; 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()) { if (!faqFile.exists()) {
try { try {
faqFile.getParentFile().mkdirs(); faqFile.getParentFile().mkdirs();
@@ -68,13 +108,12 @@ public class FaqManager {
} }
faqConfig = new YamlConfiguration(); faqConfig = new YamlConfiguration();
loadDefaults(); loadDefaults();
save(); saveToFile();
return; return;
} }
faqConfig = YamlConfiguration.loadConfiguration(faqFile); faqConfig = YamlConfiguration.loadConfiguration(faqFile);
// ── 1. Kategorien laden ───────────────────────────────────────────
ConfigurationSection catSection = faqConfig.getConfigurationSection("categories"); ConfigurationSection catSection = faqConfig.getConfigurationSection("categories");
if (catSection != null && !catSection.getKeys(false).isEmpty()) { if (catSection != null && !catSection.getKeys(false).isEmpty()) {
for (String key : catSection.getKeys(false)) { for (String key : catSection.getKeys(false)) {
@@ -85,13 +124,8 @@ public class FaqManager {
String desc = cat.getString("description", ""); String desc = cat.getString("description", "");
categories.put(key.toLowerCase(), new FaqCategory(key, name, color, desc)); 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"); ConfigurationSection faqSection = faqConfig.getConfigurationSection("faqs");
if (faqSection != null) { if (faqSection != null) {
for (String key : faqSection.getKeys(false)) { for (String key : faqSection.getKeys(false)) {
@@ -113,7 +147,8 @@ public class FaqManager {
entries.sort(Comparator.comparingInt(FaqEntry::getId)); entries.sort(Comparator.comparingInt(FaqEntry::getId));
if (plugin.isDebug()) { 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" "Material/Textur der Kategorie-Items: config.yml → gui-settings.faq.category-head-item"
); );
writeCategory("general", "Allgemein", "&e", "Allgemeine Fragen zum Server"); writeFileCategory("general", "Allgemein", "&e", "Allgemeine Fragen zum Server");
writeCategory("rules", "Regeln", "&c", "Fragen zu den Server-Regeln"); writeFileCategory("rules", "Regeln", "&c", "Fragen zu den Server-Regeln");
writeCategory("gameplay", "Gameplay", "&a", "Fragen zum Spielgeschehen"); writeFileCategory("gameplay", "Gameplay", "&a", "Fragen zum Spielgeschehen");
writeCategory("tickets", "Tickets", "&b", "Fragen zum Ticket-System"); writeFileCategory("tickets", "Tickets", "&b", "Fragen zum Ticket-System");
categories.put("general", new FaqCategory("general", "Allgemein", "&e", "Allgemeine Fragen zum Server")); 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("rules", new FaqCategory("rules", "Regeln", "&c", "Fragen zu den Server-Regeln"));
categories.put("gameplay", new FaqCategory("gameplay", "Gameplay", "&a", "Fragen zum Spielgeschehen")); categories.put("gameplay", new FaqCategory("gameplay", "Gameplay", "&a", "Fragen zum Spielgeschehen"));
categories.put("tickets", new FaqCategory("tickets", "Tickets", "&b", "Fragen zum Ticket-System")); 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] <Beschreibung>.", "tickets"); "Nutze den Befehl /ticket create [Kategorie] [Prio] <Beschreibung>.", "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"); "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"); "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 <ID> good/bad nach dem Schließen.", "tickets"); "Mit /ticket rate <ID> good/bad nach dem Schließen.", "tickets");
nextId = 5; nextId = 5;
@@ -155,13 +190,13 @@ public class FaqManager {
// ── YAML-Hilfsmethoden ───────────────────────────────────────────────── // ── 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 + ".name", name);
faqConfig.set("categories." + key + ".color", color); faqConfig.set("categories." + key + ".color", color);
faqConfig.set("categories." + key + ".description", description); 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 + ".question", question);
faqConfig.set("faqs." + id + ".answer", answer); faqConfig.set("faqs." + id + ".answer", answer);
if (categoryKey != null && !categoryKey.equals(UNCATEGORIZED_KEY)) { 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 { try {
faqConfig.save(faqFile); faqConfig.save(faqFile);
} catch (IOException e) { } catch (IOException e) {
@@ -177,7 +213,9 @@ public class FaqManager {
} }
} }
// ─────────────────────────── Public API Kategorien ─────────────────── // ═══════════════════════════════════════════════════════════════════════
// PUBLIC API KATEGORIEN
// ═══════════════════════════════════════════════════════════════════════
public boolean hasCategoriesEnabled() { return !categories.isEmpty(); } public boolean hasCategoriesEnabled() { return !categories.isEmpty(); }
@@ -190,56 +228,62 @@ public class FaqManager {
return categories.get(key.toLowerCase()); 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) { public FaqCategory addCategory(String key, String name, String color, String description) {
String lowerKey = key.toLowerCase().replaceAll("\\s+", "_"); String lowerKey = key.toLowerCase().replaceAll("\\s+", "_");
if (categories.containsKey(lowerKey)) return null; if (categories.containsKey(lowerKey)) return null;
FaqCategory cat = new FaqCategory(lowerKey, name, color, description); FaqCategory cat = new FaqCategory(lowerKey, name, color, description);
categories.put(lowerKey, cat); 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; 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) { public boolean editCategory(String key, String name, String color, String description) {
String lowerKey = key.toLowerCase(); String lowerKey = key.toLowerCase();
if (!categories.containsKey(lowerKey)) return false; if (!categories.containsKey(lowerKey)) return false;
FaqCategory updated = new FaqCategory(lowerKey, name, color, description);
categories.put(lowerKey, updated); categories.put(lowerKey, new FaqCategory(lowerKey, name, color, description));
writeCategory(lowerKey, name, color, description);
save(); if (useMysql) {
return plugin.getDatabaseManager().updateFaqCategory(lowerKey, name, color, description);
} else {
writeFileCategory(lowerKey, name, color, description);
saveToFile();
return true; 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) { public boolean deleteCategory(String key) {
String lowerKey = key.toLowerCase(); String lowerKey = key.toLowerCase();
if (!categories.containsKey(lowerKey)) return false; if (!categories.containsKey(lowerKey)) return false;
categories.remove(lowerKey); categories.remove(lowerKey);
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); faqConfig.set("categories." + lowerKey, null);
// FAQs dieser Kategorie auf "keine Kategorie" setzen
for (FaqEntry entry : entries) { for (FaqEntry entry : entries) {
if (lowerKey.equals(entry.getCategoryKey())) { if (lowerKey.equals(entry.getCategoryKey())) {
entry.setCategoryKey(UNCATEGORIZED_KEY); entry.setCategoryKey(UNCATEGORIZED_KEY);
faqConfig.set("faqs." + entry.getId() + ".category", null); faqConfig.set("faqs." + entry.getId() + ".category", null);
} }
} }
save(); saveToFile();
return true; return true;
} }
}
public List<FaqEntry> getByCategory(String categoryKey) { public List<FaqEntry> getByCategory(String categoryKey) {
String normalizedKey = normalizeCategoryKey(categoryKey); String normalizedKey = normalizeCategoryKey(categoryKey);
@@ -254,7 +298,9 @@ public class FaqManager {
return getByCategory(categoryKey).size(); return getByCategory(categoryKey).size();
} }
// ─────────────────────────── Public API Einträge ───────────────────── // ═══════════════════════════════════════════════════════════════════════
// PUBLIC API EINTRÄGE
// ═══════════════════════════════════════════════════════════════════════
public List<FaqEntry> getAll() { return Collections.unmodifiableList(entries); } public List<FaqEntry> getAll() { return Collections.unmodifiableList(entries); }
@@ -263,14 +309,24 @@ public class FaqManager {
} }
public FaqEntry add(String question, String answer, String categoryKey) { public FaqEntry add(String question, String answer, String categoryKey) {
int id = nextId++;
String normalizedKey = normalizeCategoryKey(categoryKey); String normalizedKey = normalizeCategoryKey(categoryKey);
if (useMysql) {
int id = plugin.getDatabaseManager().createFaqEntry(question, answer, normalizedKey);
if (id == -1) return null;
FaqEntry entry = new FaqEntry(id, question, answer); FaqEntry entry = new FaqEntry(id, question, answer);
entry.setCategoryKey(normalizedKey); entry.setCategoryKey(normalizedKey);
entries.add(entry); entries.add(entry);
writeEntry(id, question, answer, normalizedKey);
save();
return 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) { public FaqEntry add(String question, String answer) {
@@ -280,39 +336,193 @@ public class FaqManager {
public boolean edit(int id, String question, String answer) { public boolean edit(int id, String question, String answer) {
FaqEntry entry = getById(id); FaqEntry entry = getById(id);
if (entry == null) return false; if (entry == null) return false;
entry.setQuestion(question); entry.setQuestion(question);
entry.setAnswer(answer); entry.setAnswer(answer);
writeEntry(id, question, answer, entry.getCategoryKey());
save(); if (useMysql) {
return plugin.getDatabaseManager().updateFaqEntry(id, question, answer);
} else {
writeFileEntry(id, question, answer, entry.getCategoryKey());
saveToFile();
return true; return true;
} }
}
public boolean setCategory(int id, String categoryKey) { public boolean setCategory(int id, String categoryKey) {
FaqEntry entry = getById(id); FaqEntry entry = getById(id);
if (entry == null) return false; if (entry == null) return false;
String normalizedKey = normalizeCategoryKey(categoryKey); String normalizedKey = normalizeCategoryKey(categoryKey);
entry.setCategoryKey(normalizedKey); entry.setCategoryKey(normalizedKey);
if (useMysql) {
return plugin.getDatabaseManager().updateFaqEntryCategory(id, normalizedKey);
} else {
if (normalizedKey.equals(UNCATEGORIZED_KEY)) { if (normalizedKey.equals(UNCATEGORIZED_KEY)) {
faqConfig.set("faqs." + id + ".category", null); faqConfig.set("faqs." + id + ".category", null);
} else { } else {
faqConfig.set("faqs." + id + ".category", normalizedKey); faqConfig.set("faqs." + id + ".category", normalizedKey);
} }
save(); saveToFile();
return true; return true;
} }
}
public boolean delete(int id) { public boolean delete(int id) {
FaqEntry entry = getById(id); FaqEntry entry = getById(id);
if (entry == null) return false; if (entry == null) return false;
entries.remove(entry); entries.remove(entry);
if (useMysql) {
return plugin.getDatabaseManager().deleteFaqEntry(id);
} else {
faqConfig.set("faqs." + id, null); faqConfig.set("faqs." + id, null);
save(); saveToFile();
return true; return true;
} }
}
public void reload() { load(); } 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<String> 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<Integer> 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) { private String normalizeCategoryKey(String key) {
if (key == null || key.isBlank()) return UNCATEGORIZED_KEY; if (key == null || key.isBlank()) return UNCATEGORIZED_KEY;

View File

@@ -36,6 +36,21 @@ public class SessionManager {
private final Map<String, WebSession> sessions = new ConcurrentHashMap<>(); private final Map<String, WebSession> sessions = new ConcurrentHashMap<>();
private final SecureRandom random = new SecureRandom(); private final SecureRandom random = new SecureRandom();
// ── Rate-Limiting: fehlgeschlagene Login-Versuche pro IP ──────────────
/** Anzahl fehlgeschlagener Versuche pro IP */
private final Map<String, Integer> failedAttempts = new ConcurrentHashMap<>();
/** Zeitpunkt der letzten fehlgeschlagenen Anfrage pro IP */
private final Map<String, Long> lastFailTime = new ConcurrentHashMap<>();
/** Zeitpunkt bis zu dem eine IP gesperrt ist (Epoch-ms) */
private final Map<String, Long> 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) { public SessionManager(TicketPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
migratePlaintextPasswords(); migratePlaintextPasswords();
@@ -45,22 +60,56 @@ public class SessionManager {
/** /**
* Prüft Benutzername + Passwort und erstellt bei Erfolg eine Session. * 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; 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"); ConfigurationSection users = plugin.getConfig().getConfigurationSection("web-panel.users");
if (users == null) return null; if (users == null) return null;
ConfigurationSection user = users.getConfigurationSection(username.toLowerCase()); // Case-insensitiver Lookup
if (user == null) return null; 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 storedHash = user.getString("password-hash", "");
String inputHash = sha256(password); 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(); String roleStr = user.getString("role", "supporter").toUpperCase();
WebSession.Role role; WebSession.Role role;
@@ -72,16 +121,81 @@ public class SessionManager {
long timeoutMs = plugin.getConfig().getLong("web-panel.session-timeout-minutes", 60) * 60_000L; long timeoutMs = plugin.getConfig().getLong("web-panel.session-timeout-minutes", 60) * 60_000L;
String token = generateToken(); String token = generateToken();
WebSession session = new WebSession(token, username.toLowerCase(), role, timeoutMs); WebSession session = new WebSession(token, matchedKey, role, timeoutMs);
sessions.put(token, session); sessions.put(token, session);
if (plugin.isDebug()) { if (plugin.isDebug())
plugin.getLogger().info("[WebPanel] Login: " + username + " (" + role + ")"); plugin.getLogger().info("[WebPanel] Login: " + matchedKey + " (" + role + ")"
} + (clientIp != null ? " von " + clientIp : ""));
return token; 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. * Gibt die Session für ein Token zurück, oder null wenn ungültig/abgelaufen.
* Erneuert den lastAccess-Zeitstempel bei gültiger Session. * 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() { public void evictExpired() {
Iterator<Map.Entry<String, WebSession>> it = sessions.entrySet().iterator(); Iterator<Map.Entry<String, WebSession>> it = sessions.entrySet().iterator();
while (it.hasNext()) { while (it.hasNext()) {
if (it.next().getValue().isExpired()) it.remove(); 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(); } public int activeSessionCount() { return sessions.size(); }

View File

@@ -1,21 +1,43 @@
package de.ticketsystem.web; package de.ticketsystem.web;
import com.sun.net.httpserver.HttpServer; 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.TicketPlugin;
import de.ticketsystem.web.handlers.*; import de.ticketsystem.web.handlers.*;
import javax.net.ssl.*;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.security.KeyStore;
import java.util.concurrent.Executors; 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: * Konfiguration in config.yml:
* web-panel: * web-panel:
* enabled: true * enabled: true
* port: 8085 * 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 { public class WebServer {
@@ -31,12 +53,22 @@ public class WebServer {
public void start() { public void start() {
int port = plugin.getConfig().getInt("web-panel.port", 8085); int port = plugin.getConfig().getInt("web-panel.port", 8085);
String bindStr = plugin.getConfig().getString("web-panel.bind-address", "0.0.0.0"); String bindStr = plugin.getConfig().getString("web-panel.bind-address", "0.0.0.0");
boolean sslEnabled = plugin.getConfig().getBoolean("web-panel.ssl.enabled", false);
try { try {
InetSocketAddress addr = new InetSocketAddress(bindStr, port); 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)); server.setExecutor(Executors.newFixedThreadPool(4));
// ── Routes registrieren ────────────────────────────────────── // ── Routes registrieren ──────────────────────────────────────
@@ -59,21 +91,82 @@ public class WebServer {
server.start(); 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 // Session-Cleanup alle 10 Minuten
plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin,
() -> sessionManager.evictExpired(), 12000L, 12000L); () -> sessionManager.evictExpired(), 12000L, 12000L);
} catch (IOException e) { } 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() { public void stop() {
if (server != null) { if (server != null) {
server.stop(1); 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; }
} }

View File

@@ -7,7 +7,8 @@ public class WebSession {
public enum Role { public enum Role {
ADMIN, // Voller Zugriff: Tickets, FAQ, Stats, Blacklist, Reload ADMIN, // Voller Zugriff: Tickets, FAQ, Stats, Blacklist, Reload
SUPPORTER // Tickets anzeigen, claimen, schließen, kommentieren SUPPORTER, // Tickets anzeigen, claimen, schließen, kommentieren
ARCHIVE_VIEWER // Wie SUPPORTER + Zugriff auf das Archiv
} }
private final String token; private final String token;
@@ -36,4 +37,10 @@ public class WebSession {
public String getUsername() { return username; } public String getUsername() { return username; }
public Role getRole() { return role; } public Role getRole() { return role; }
public boolean isAdmin() { return role == Role.ADMIN; } 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; }
} }

View File

@@ -60,12 +60,36 @@ public class ApiHandler extends BaseHandler implements HttpHandler {
return; return;
} }
// ── Archiv-Aktionen ──────────────────────────────────────────
if (path.startsWith("/api/archive/")) {
handleArchiveApi(ex, session, path, method);
return;
}
// ── FAQ-Aktionen ───────────────────────────────────────────── // ── FAQ-Aktionen ─────────────────────────────────────────────
if (path.startsWith("/api/faq")) { if (path.startsWith("/api/faq")) {
handleFaqApi(ex, session, path, method); handleFaqApi(ex, session, path, method);
return; 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\"}"); sendJson(ex, 404, "{\"ok\":false,\"error\":\"Unbekannter Endpunkt\"}");
} catch (Exception e) { } catch (Exception e) {
@@ -287,6 +311,156 @@ public class ApiHandler extends BaseHandler implements HttpHandler {
sendJson(ex, 404, err("Unbekannter FAQ-Endpunkt")); 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<String, String> 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<String, String> 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 ───────────────────────────── // ─────────────────────────── Hilfsmethoden ─────────────────────────────
/** /**

View File

@@ -322,6 +322,15 @@ public abstract class BaseHandler {
""".formatted(title, title, message, wl(plugin, "btn-back")); """.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) { protected String escHtml(String s) {
if (s == null) return ""; if (s == null) return "";
return s.replace("&", "&amp;") return s.replace("&", "&amp;")

View File

@@ -60,11 +60,26 @@ public class TicketsHandler extends BaseHandler implements HttpHandler {
int page = parseInt(params.getOrDefault("page", "1"), 1); int page = parseInt(params.getOrDefault("page", "1"), 1);
DatabaseManager db = plugin.getDatabaseManager(); DatabaseManager db = plugin.getDatabaseManager();
List<Ticket> all = db.getAllTickets(); List<Ticket> 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 ── // ── Filter ──
List<Ticket> filtered = all.stream() List<Ticket> 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 -> filterCat.equals("all") || t.getCategoryKey().equalsIgnoreCase(filterCat))
.filter(t -> filterPrio.equals("all") || t.getPriority().name().equalsIgnoreCase(filterPrio)) .filter(t -> filterPrio.equals("all") || t.getPriority().name().equalsIgnoreCase(filterPrio))
.filter(t -> filterSearch.isEmpty() .filter(t -> filterSearch.isEmpty()
@@ -83,27 +98,38 @@ public class TicketsHandler extends BaseHandler implements HttpHandler {
List<Ticket> pageTickets = filtered.subList(fromIdx, toIdx); List<Ticket> pageTickets = filtered.subList(fromIdx, toIdx);
String content = buildList(pageTickets, total, page, totalPages, 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)); sendHtml(ex, 200, layout(wl(plugin, "tickets-title"), content, session, plugin));
} }
private String buildList(List<Ticket> tickets, int total, int page, int totalPages, private String buildList(List<Ticket> 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(); StringBuilder sb = new StringBuilder();
sb.append("<h1 class='page-title'>") sb.append("<div style='display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem'>");
sb.append("<h1 class='page-title' style='margin:0'>")
.append(escHtml(wl(plugin, "tickets-title"))) .append(escHtml(wl(plugin, "tickets-title")))
.append(" <span>").append(total).append(" ") .append(" <span>").append(total).append(" ")
.append(escHtml(wl(plugin, "tickets-total"))) .append(escHtml(wl(plugin, "tickets-total")))
.append("</span></h1>"); .append("</span></h1>");
if (session.canViewArchive()) {
sb.append("<a href='/tickets?status=ARCHIVED' class='btn btn-sm btn-secondary'>")
.append("🗃 ").append(escHtml(wl(plugin, "nav-archive"))).append("</a>");
}
sb.append("</div>");
// ── Filter-Bar ── // ── Filter-Bar ──
sb.append("<div class='filter-bar'>"); sb.append("<div class='filter-bar'>");
sb.append(selectFilter("status", filterStatus, List.of( List<Map.Entry<String,String>> statusOpts = new ArrayList<>(List.of(
entry("all", wl(plugin, "filter-all-status")), entry("all", wl(plugin, "filter-all-status")),
entry("OPEN", wl(plugin, "filter-open")), entry("OPEN", wl(plugin, "filter-open")),
entry("CLAIMED", wl(plugin, "filter-claimed")), entry("CLAIMED", wl(plugin, "filter-claimed")),
entry("FORWARDED", wl(plugin, "filter-forwarded")), 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)) { if (plugin.getConfig().getBoolean("categories-enabled", true)) {
List<Map.Entry<String,String>> catOpts = new ArrayList<>(); List<Map.Entry<String,String>> 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 { private void handleDetail(HttpExchange ex, WebSession session, int ticketId) throws IOException {
Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId); 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) { if (ticket == null) {
sendHtml(ex, 404, errorPage( sendHtml(ex, 404, errorPage(
wl(plugin, "detail-not-found"), wl(plugin, "detail-not-found"),
@@ -202,12 +238,18 @@ public class TicketsHandler extends BaseHandler implements HttpHandler {
return; return;
} }
List<TicketComment> comments = plugin.getDatabaseManager().getComments(ticketId); List<TicketComment> comments = fromArchive
String content = buildDetail(ticket, comments, session); ? List.of()
: plugin.getDatabaseManager().getComments(ticketId);
String content = buildDetail(ticket, comments, session, fromArchive);
sendHtml(ex, 200, layout("Ticket #" + ticketId, content, session, plugin)); sendHtml(ex, 200, layout("Ticket #" + ticketId, content, session, plugin));
} }
private String buildDetail(Ticket t, List<TicketComment> comments, WebSession session) { private String buildDetail(Ticket t, List<TicketComment> comments, WebSession session) {
return buildDetail(t, comments, session, false);
}
private String buildDetail(Ticket t, List<TicketComment> comments, WebSession session, boolean fromArchive) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
String created = t.getCreatedAt() != null ? SDF.format(t.getCreatedAt()) : ""; String created = t.getCreatedAt() != null ? SDF.format(t.getCreatedAt()) : "";
String catName = getCategoryName(t); String catName = getCategoryName(t);
@@ -257,7 +299,7 @@ public class TicketsHandler extends BaseHandler implements HttpHandler {
} }
// ── Aktionen (nur bei aktiven Tickets) ── // ── Aktionen (nur bei aktiven Tickets) ──
if (t.getStatus() != TicketStatus.CLOSED) { if (t.getStatus() != TicketStatus.CLOSED && !fromArchive) {
sb.append("<div class='card'><div class='card-title'>") sb.append("<div class='card'><div class='card-title'>")
.append(escHtml(wl(plugin, "detail-section-actions"))) .append(escHtml(wl(plugin, "detail-section-actions")))
.append("</div><div style='display:flex;flex-wrap:wrap;gap:.75rem'>"); .append("</div><div style='display:flex;flex-wrap:wrap;gap:.75rem'>");
@@ -302,6 +344,18 @@ public class TicketsHandler extends BaseHandler implements HttpHandler {
sb.append("</div></div>"); sb.append("</div></div>");
} }
// ── Archiv-Aktionen (nur für archivierte Tickets) ──
if (fromArchive && session.isAdmin()) {
sb.append("<div class='card'><div class='card-title'>")
.append(escHtml(wl(plugin, "detail-section-actions")))
.append("</div><div style='display:flex;gap:.75rem'>");
sb.append("<button class='btn btn-success' onclick='restoreTicket(").append(t.getId()).append(")'>")
.append("").append(escHtml(wl(plugin, "archive-btn-restore"))).append("</button>");
sb.append("<button class='btn btn-danger' onclick='deleteArchivedTicket(").append(t.getId()).append(")'>")
.append("🗑 ").append(escHtml(wl(plugin, "archive-btn-delete"))).append("</button>");
sb.append("</div></div>");
}
// ── Kommentare ── // ── Kommentare ──
sb.append("<div class='card'><div class='card-title'>") sb.append("<div class='card'><div class='card-title'>")
.append(escHtml(wl(plugin, "detail-section-comments"))) .append(escHtml(wl(plugin, "detail-section-comments")))

View File

@@ -14,7 +14,7 @@
# --- GRUNDLEGEND --- # --- GRUNDLEGEND ---
# Version der Konfigurationsdatei. Nicht ändern! # Version der Konfigurationsdatei. Nicht ändern!
version: "2.4" version: "2.5"
# ---------------------------------------------------- # ----------------------------------------------------
# SPRACHE / LANGUAGE # SPRACHE / LANGUAGE
@@ -307,6 +307,24 @@ web-panel:
enabled: false enabled: false
port: 8085 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: # Bind-Adresse:
# "0.0.0.0" = alle Interfaces (direkt erreichbar) # "0.0.0.0" = alle Interfaces (direkt erreichbar)
# "127.0.0.1" = nur lokal (Reverse-Proxy empfohlen) # "127.0.0.1" = nur lokal (Reverse-Proxy empfohlen)
@@ -321,8 +339,13 @@ web-panel:
# ── Benutzer ────────────────────────────────────────────── # ── Benutzer ──────────────────────────────────────────────
# Rollen: # Rollen:
# admin → Voller Zugriff: Tickets, FAQ, Weiterleiten # admin → Voller Zugriff: Tickets, FAQ, Weiterleiten, Archiv
# supporter → Tickets anzeigen, claimen, schließen, kommentieren # 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: # Passwort setzen:
# Trage "password: deinPasswort" ein. # Trage "password: deinPasswort" ein.
@@ -336,3 +359,7 @@ web-panel:
supporter: supporter:
password: "aendere_mich" password: "aendere_mich"
role: "supporter" role: "supporter"
# Beispiel: Supporter mit Archiv-Zugriff
# archiv-viewer:
# password: "deinPasswort"
# role: "archive_viewer"

View File

@@ -46,6 +46,14 @@ system:
file-not-found: "&cDatei nicht gefunden: &e{file}" file-not-found: "&cDatei nicht gefunden: &e{file}"
unknown-mode: "&cUnbekannter Modus! Benutze: tomysql oder tofile" unknown-mode: "&cUnbekannter Modus! Benutze: tomysql oder tofile"
validation-warning: "&cEs wurden &e{count} &cungültige Tickets beim Laden gefunden." 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!" db-create-error: "&cFehler beim Erstellen des Tickets!"
# ============================================================ # ============================================================
@@ -242,6 +250,12 @@ faq:
deleted: "&aFAQ &e#{id} &awurde gelöscht." deleted: "&aFAQ &e#{id} &awurde gelöscht."
not-found: "&cFAQ &e#{id} &cwurde nicht gefunden." not-found: "&cFAQ &e#{id} &cwurde nicht gefunden."
reloaded: "&aFAQs wurden neu geladen. ({count} Einträge)" 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-header: "&6Häufige Fragen (FAQ) &7— {count} Einträge"
list-empty: "&7Noch keine FAQs vorhanden." list-empty: "&7Noch keine FAQs vorhanden."
list-entry: "&e#{id} &f{question}" list-entry: "&e#{id} &f{question}"
@@ -249,7 +263,7 @@ faq:
list-admin-hint: "&7Befehle: &e{cmd_faq} add &8| &e{cmd_faq} edit <ID> &8| &e{cmd_faq} delete <ID>" list-admin-hint: "&7Befehle: &e{cmd_faq} add &8| &e{cmd_faq} edit <ID> &8| &e{cmd_faq} delete <ID>"
unknown-sub: "&cUnbekannter FAQ-Befehl." unknown-sub: "&cUnbekannter FAQ-Befehl."
hint-open: "&7Benutze &e{cmd_faq} &7zum Öffnen der GUI." 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) # FAQ-KATEGORIEN BEFEHL (/ticket kategorie)
@@ -559,6 +573,9 @@ web:
login-heading: "Willkommen zurück" login-heading: "Willkommen zurück"
login-sub: "Melde dich mit deinem Account an, um fortzufahren." login-sub: "Melde dich mit deinem Account an, um fortzufahren."
login-error: "Benutzername oder Passwort falsch." 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-user: "Benutzername"
login-label-pass: "Passwort" login-label-pass: "Passwort"
login-btn: "Anmelden" login-btn: "Anmelden"

View File

@@ -46,6 +46,14 @@ system:
file-not-found: "&cFile not found: &e{file}" file-not-found: "&cFile not found: &e{file}"
unknown-mode: "&cUnknown mode! Use: tomysql or tofile" unknown-mode: "&cUnknown mode! Use: tomysql or tofile"
validation-warning: "&c&e{count} &cinvalid tickets were found during loading." 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!" db-create-error: "&cFailed to create the ticket!"
# ============================================================ # ============================================================
@@ -242,6 +250,12 @@ faq:
deleted: "&aFAQ &e#{id} &ahas been deleted." deleted: "&aFAQ &e#{id} &ahas been deleted."
not-found: "&cFAQ &e#{id} &cwas not found." not-found: "&cFAQ &e#{id} &cwas not found."
reloaded: "&aFAQs reloaded. ({count} entries)" 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-header: "&6Frequently Asked Questions (FAQ) &7— {count} entries"
list-empty: "&7No FAQs available yet." list-empty: "&7No FAQs available yet."
list-entry: "&e#{id} &f{question}" list-entry: "&e#{id} &f{question}"
@@ -249,7 +263,7 @@ faq:
list-admin-hint: "&7Commands: &e{cmd_faq} add &8| &e{cmd_faq} edit <ID> &8| &e{cmd_faq} delete <ID>" list-admin-hint: "&7Commands: &e{cmd_faq} add &8| &e{cmd_faq} edit <ID> &8| &e{cmd_faq} delete <ID>"
unknown-sub: "&cUnknown FAQ subcommand." unknown-sub: "&cUnknown FAQ subcommand."
hint-open: "&7Use &e{cmd_faq} &7to open the GUI." 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) # FAQ CATEGORY COMMAND (/ticket category)
@@ -558,7 +572,10 @@ web:
login-title: "Login TicketSystem Panel" login-title: "Login TicketSystem Panel"
login-heading: "Welcome back" login-heading: "Welcome back"
login-sub: "Sign in with your account to continue." 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." login-error: "Username or password incorrect."
archive-btn-restore: "Restore"
archive-btn-delete: "Delete permanently"
login-label-user: "Username" login-label-user: "Username"
login-label-pass: "Password" login-label-pass: "Password"
login-btn: "Sign In" login-btn: "Sign In"