Files
TicketSystem/src/main/java/de/ticketsystem/web/handlers/ApiHandler.java
2026-04-16 11:48:01 +02:00

506 lines
25 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<TicketComment> 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<de.ticketsystem.model.FaqEntry> 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<String, String> 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<String, String> 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<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 ─────────────────────────────
/**
* Liest einen JSON-Body und parst ihn als flaches Key-Value-Objekt.
* Kein externer JSON-Parser minimale Eigenimplementierung.
*/
private Map<String, String> 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<String, String> 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", "");
}
}