package de.ticketsystem.web.handlers; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import de.ticketsystem.TicketPlugin; import de.ticketsystem.database.DatabaseManager; import de.ticketsystem.model.*; import de.ticketsystem.web.SessionManager; import de.ticketsystem.web.WebSession; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.UUID; /** * /api/* – JSON-API für AJAX-Aktionen aus dem Web-Panel. * * Alle Responses: * Erfolg: {"ok":true} * Fehler: {"ok":false,"error":"Beschreibung"} * * Endpunkte: * POST /api/ticket/{id}/claim * POST /api/ticket/{id}/close body: {comment?} * POST /api/ticket/{id}/priority body: {priority} * POST /api/ticket/{id}/comment body: {message} * POST /api/ticket/{id}/forward body: {target} (admin only) * GET /api/ticket/{id}/comments * * GET /api/faq * POST /api/faq body: {question, answer, category?} (admin only) * PUT /api/faq/{id} body: {question, answer, category?} (admin only) * DELETE /api/faq/{id} (admin only) */ public class ApiHandler extends BaseHandler implements HttpHandler { private final TicketPlugin plugin; private final SessionManager sessionManager; public ApiHandler(TicketPlugin plugin, SessionManager sessionManager) { this.plugin = plugin; this.sessionManager = sessionManager; } @Override public void handle(HttpExchange ex) throws IOException { WebSession session = requireSession(ex, sessionManager); if (session == null) return; String path = ex.getRequestURI().getPath(); // z.B. /api/ticket/5/claim String method = ex.getRequestMethod().toUpperCase(); try { // ── Ticket-Aktionen ────────────────────────────────────────── if (path.startsWith("/api/ticket/")) { handleTicketApi(ex, session, path, method); 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) { plugin.getLogger().warning("[WebPanel/API] Fehler: " + e.getMessage()); if (plugin.isDebug()) e.printStackTrace(); sendJson(ex, 500, "{\"ok\":false,\"error\":\"Interner Fehler\"}"); } } // ─────────────────────────── Ticket-API ──────────────────────────────── private void handleTicketApi(HttpExchange ex, WebSession session, String path, String method) throws IOException { // /api/ticket/{id}/{action} String[] parts = path.split("/"); // ["", "api", "ticket", "5", "claim"] 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 Ticket-ID")); return; } String action = parts[4]; // claim | close | priority | comment | forward | comments DatabaseManager db = plugin.getDatabaseManager(); Ticket ticket = db.getTicketById(ticketId); if (ticket == null) { sendJson(ex, 404, err("Ticket nicht gefunden")); return; } switch (action) { case "claim" -> { if (!method.equals("POST")) { sendJson(ex, 405, err("Method not allowed")); return; } if (ticket.getStatus() != TicketStatus.OPEN) { sendJson(ex, 400, err("Ticket ist nicht offen")); return; } // UUID des Web-Users → wir nutzen einen Pseudo-UUID aus dem Benutzernamen UUID webUUID = webUserUUID(session.getUsername()); boolean ok = db.claimTicket(ticketId, webUUID, "[Web] " + session.getUsername()); if (ok) { ticket.setClaimerName("[Web] " + session.getUsername()); plugin.getTicketManager().notifyCreatorClaimed(ticket); plugin.getTicketCache().invalidate(ticketId); sendJson(ex, 200, "{\"ok\":true}"); } else { sendJson(ex, 500, err("Claim fehlgeschlagen")); } } case "close" -> { if (!method.equals("POST")) { sendJson(ex, 405, err("Method not allowed")); return; } Map body = parseJsonBody(ex); String comment = body.getOrDefault("comment", ""); boolean ok = db.closeTicket(ticketId, comment.isEmpty() ? null : comment); if (ok) { ticket.setStatus(TicketStatus.CLOSED); ticket.setCloseComment(comment); plugin.getTicketManager().notifyCreatorClosed(ticket, "[Web] " + session.getUsername()); plugin.getTicketCache().invalidate(ticketId); sendJson(ex, 200, "{\"ok\":true}"); } else { sendJson(ex, 500, err("Schließen fehlgeschlagen")); } } case "priority" -> { if (!method.equals("POST")) { sendJson(ex, 405, err("Method not allowed")); return; } Map body = parseJsonBody(ex); String prioStr = body.getOrDefault("priority", "NORMAL"); TicketPriority prio = TicketPriority.fromString(prioStr); boolean ok = db.setTicketPriority(ticketId, prio); if (ok) { plugin.getTicketCache().invalidate(ticketId); sendJson(ex, 200, "{\"ok\":true}"); } else { sendJson(ex, 500, err("Priorität setzen fehlgeschlagen")); } } case "comment" -> { if (!method.equals("POST")) { sendJson(ex, 405, err("Method not allowed")); return; } Map body = parseJsonBody(ex); String msg = body.getOrDefault("message", "").trim(); if (msg.isEmpty()) { sendJson(ex, 400, err("Nachricht leer")); return; } UUID webUUID = webUserUUID(session.getUsername()); String authorDisplay = "[Web] " + session.getUsername(); TicketComment comment = new TicketComment(ticketId, webUUID, authorDisplay, msg); boolean ok = db.addComment(comment); if (ok) { // Ersteller benachrichtigen (online oder Pending) String notifyMsg = plugin.lang().format("comment.notify-online", "{id}", String.valueOf(ticketId), "{player}", authorDisplay, "{message}", msg); Bukkit.getScheduler().runTask(plugin, () -> { org.bukkit.entity.Player creator = Bukkit.getPlayer(ticket.getCreatorUUID()); if (creator != null && creator.isOnline()) { creator.sendMessage(notifyMsg); } else { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> db.addPendingNotification(ticket.getCreatorUUID(), notifyMsg)); } }); sendJson(ex, 200, "{\"ok\":true}"); } else { sendJson(ex, 500, err("Kommentar speichern fehlgeschlagen")); } } case "forward" -> { if (!method.equals("POST")) { sendJson(ex, 405, err("Method not allowed")); return; } if (!session.isAdmin()) { sendJson(ex, 403, err("Kein Zugriff")); return; } Map body = parseJsonBody(ex); String targetName = body.getOrDefault("target", "").trim(); if (targetName.isEmpty()) { sendJson(ex, 400, err("Ziel fehlt")); return; } // Spieler-UUID ermitteln: erst Online-Spieler, dann Offline-Cache OfflinePlayer target = null; for (var p : Bukkit.getOnlinePlayers()) { if (p.getName().equalsIgnoreCase(targetName)) { target = p; break; } } if (target == null) { // getOfflinePlayer lädt ggf. aus dem Usercache – nie null, aber // hasPlayedBefore() == false bedeutet: unbekannter Spieler OfflinePlayer op = Bukkit.getOfflinePlayer(targetName); if (op.hasPlayedBefore()) target = op; } if (target == null) { sendJson(ex, 404, err("Spieler nicht gefunden: " + targetName)); return; } boolean ok = db.forwardTicket(ticketId, target.getUniqueId(), target.getName()); if (ok) { ticket.setForwardedToUUID(target.getUniqueId()); ticket.setForwardedToName(target.getName()); plugin.getTicketManager().notifyForwardedTo(ticket, "[Web] " + session.getUsername()); plugin.getTicketManager().notifyCreatorForwarded(ticket); plugin.getTicketCache().invalidate(ticketId); sendJson(ex, 200, "{\"ok\":true}"); } else { sendJson(ex, 500, err("Weiterleiten fehlgeschlagen")); } } case "comments" -> { if (!method.equals("GET")) { sendJson(ex, 405, err("Method not allowed")); return; } List comments = db.getComments(ticketId); StringBuilder json = new StringBuilder("["); for (int i = 0; i < comments.size(); i++) { TicketComment c = comments.get(i); if (i > 0) json.append(","); json.append("{\"author\":\"").append(jsonEsc(c.getAuthorName())) .append("\",\"message\":\"").append(jsonEsc(c.getMessage())) .append("\",\"time\":\"").append(c.getCreatedAt() != null ? c.getCreatedAt().getTime() : 0) .append("\"}"); } json.append("]"); sendJson(ex, 200, json.toString()); } default -> sendJson(ex, 404, err("Unbekannte Aktion: " + action)); } } // ─────────────────────────── FAQ-API ─────────────────────────────────── private void handleFaqApi(HttpExchange ex, WebSession session, String path, String method) throws IOException { // GET /api/faq – Liste if (path.equals("/api/faq") && method.equals("GET")) { List entries = plugin.getFaqManager().getAll(); StringBuilder json = new StringBuilder("["); for (int i = 0; i < entries.size(); i++) { var e = entries.get(i); if (i > 0) json.append(","); json.append("{\"id\":").append(e.getId()) .append(",\"question\":\"").append(jsonEsc(e.getQuestion())) .append("\",\"answer\":\"").append(jsonEsc(e.getAnswer())) .append("\",\"category\":\"").append(jsonEsc(e.getCategoryKey())) .append("\"}"); } json.append("]"); sendJson(ex, 200, json.toString()); return; } // POST /api/faq – Hinzufügen (admin only) if (path.equals("/api/faq") && method.equals("POST")) { if (!session.isAdmin()) { sendJson(ex, 403, err("Kein Zugriff")); return; } Map body = parseJsonBody(ex); String q = body.getOrDefault("question", "").trim(); String a = body.getOrDefault("answer", "").trim(); String cat = body.getOrDefault("category", ""); if (q.isEmpty() || a.isEmpty()) { sendJson(ex, 400, err("Frage und Antwort erforderlich")); return; } plugin.getFaqManager().add(q, a, cat.isEmpty() ? null : cat); sendJson(ex, 200, "{\"ok\":true}"); return; } // PUT /api/faq/{id} – Bearbeiten (admin only) if (path.matches("/api/faq/\\d+") && method.equals("PUT")) { if (!session.isAdmin()) { sendJson(ex, 403, err("Kein Zugriff")); return; } int id = Integer.parseInt(path.substring("/api/faq/".length())); Map body = parseJsonBody(ex); String q = body.getOrDefault("question", "").trim(); String a = body.getOrDefault("answer", "").trim(); String cat = body.getOrDefault("category", ""); if (q.isEmpty() || a.isEmpty()) { sendJson(ex, 400, err("Frage und Antwort erforderlich")); return; } boolean ok = plugin.getFaqManager().edit(id, q, a); if (!cat.isEmpty()) plugin.getFaqManager().setCategory(id, cat); sendJson(ex, ok ? 200 : 404, ok ? "{\"ok\":true}" : err("FAQ nicht gefunden")); return; } // DELETE /api/faq/{id} – Löschen (admin only) if (path.matches("/api/faq/\\d+") && method.equals("DELETE")) { if (!session.isAdmin()) { sendJson(ex, 403, err("Kein Zugriff")); return; } int id = Integer.parseInt(path.substring("/api/faq/".length())); boolean ok = plugin.getFaqManager().delete(id); sendJson(ex, ok ? 200 : 404, ok ? "{\"ok\":true}" : err("FAQ nicht gefunden")); return; } 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 ───────────────────────────── /** * Liest einen JSON-Body und parst ihn als flaches Key-Value-Objekt. * Kein externer JSON-Parser – minimale Eigenimplementierung. */ private Map parseJsonBody(HttpExchange ex) throws IOException { java.io.InputStream is = ex.getRequestBody(); String body = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).trim(); Map result = new java.util.HashMap<>(); if (body.startsWith("{") && body.endsWith("}")) { body = body.substring(1, body.length() - 1); // Einfaches Parsing: "key":"value", "key2":"value2" java.util.regex.Matcher m = java.util.regex.Pattern .compile("\"([^\"]+)\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"") .matcher(body); while (m.find()) { result.put(m.group(1), m.group(2) .replace("\\\"", "\"") .replace("\\\\", "\\") .replace("\\n", "\n")); } } return result; } /** * Erstellt eine deterministische UUID aus einem Web-Benutzernamen. * Damit kann der Web-User als "Autor" in DB-Einträgen gespeichert werden. */ private UUID webUserUUID(String username) { return UUID.nameUUIDFromBytes(("webpanel:" + username).getBytes(java.nio.charset.StandardCharsets.UTF_8)); } private String err(String msg) { return "{\"ok\":false,\"error\":\"" + jsonEsc(msg) + "\"}"; } private String jsonEsc(String s) { if (s == null) return ""; return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", ""); } }