Files
TicketSystem/src/main/java/de/ticketsystem/web/handlers/BaseHandler.java
2026-04-15 19:11:02 +02:00

336 lines
15 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 de.ticketsystem.TicketPlugin;
import de.ticketsystem.web.SessionManager;
import de.ticketsystem.web.WebSession;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
/**
* Gemeinsame Hilfsmethoden für alle HTTP-Handler.
*
* Neue Methoden für i18n und Favicon:
* wl(plugin, key) Liest einen Web-Panel-Text aus der aktiven Sprachdatei (web.{key}).
* buildFaviconHtml(plugin) Erzeugt das <link rel="icon">-Tag (Logo oder Standard-SVG).
* layout(title, content, session, plugin) Layout mit lokalisierten Nav-Texten + Favicon.
* requireAdmin(ex, session, plugin) 403-Fehlerseite mit lokalisierten Texten.
* errorPage(title, message, plugin) Fehlerseite mit lokalisiertem Zurück-Button.
*
* Die parameterlos-Varianten von layout(), requireAdmin() und errorPage() bleiben
* unverändert erhalten (Rückwärtskompatibilität für StaticHandler o. Ä.).
*/
public abstract class BaseHandler {
// ─────────────────────────── Lang-Hilfsmethode ─────────────────────────
/**
* Liest einen Web-Panel-Text aus der aktiven Sprachdatei (web.{key}).
* Kein Minecraft-Farbcode-Parsing reiner Plaintext für HTML.
*/
protected String wl(TicketPlugin plugin, String key) {
return plugin.lang().getRaw("web." + key);
}
// ─────────────────────────── Favicon ───────────────────────────────────
/**
* Erzeugt das {@code <link rel="icon">} HTML-Tag für den {@code <head>}-Bereich.
*
* Wenn {@code web-panel.logo-file} in der config.yml gesetzt ist, wird das Logo
* als Favicon verwendet (wird bereits als /static/{datei} serviert).
* Ansonsten wird ein eingebettetes SVG-Icon als Data-URI erzeugt.
*
* Unterstützte Formate: png, jpg, jpeg, gif, webp, svg, ico.
*/
protected String buildFaviconHtml(TicketPlugin plugin) {
String logoFile = plugin.getConfig().getString("web-panel.logo-file", "").trim();
if (!logoFile.isEmpty()) {
String safe = logoFile.replaceAll("[^a-zA-Z0-9._\\-]", "_");
String ext = safe.contains(".") ? safe.substring(safe.lastIndexOf('.') + 1).toLowerCase() : "png";
String mime = switch (ext) {
case "svg" -> "image/svg+xml";
case "jpg", "jpeg" -> "image/jpeg";
case "gif" -> "image/gif";
case "webp" -> "image/webp";
case "ico" -> "image/x-icon";
default -> "image/png";
};
return "<link rel='icon' type='" + mime + "' href='/static/" + escHtml(safe) + "'>";
}
// Standard-SVG als Data-URI (kein externes File nötig)
String svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'>"
+ "<rect width='48' height='48' rx='10' fill='%231d4ed8'/>"
+ "<rect x='8' y='14' width='32' height='6' rx='2' fill='white' opacity='.9'/>"
+ "<rect x='8' y='23' width='20' height='4' rx='2' fill='white' opacity='.6'/>"
+ "<rect x='8' y='30' width='14' height='4' rx='2' fill='white' opacity='.4'/>"
+ "<circle cx='37' cy='32' r='6' fill='white' opacity='.15'/>"
+ "<path d='M34 32l2 2 4-4' stroke='white' stroke-width='1.8'"
+ " stroke-linecap='round' stroke-linejoin='round'/>"
+ "</svg>";
String b64 = Base64.getEncoder().encodeToString(svg.getBytes(StandardCharsets.UTF_8));
return "<link rel='icon' type='image/svg+xml' href='data:image/svg+xml;base64," + b64 + "'>";
}
// ─────────────────────────── Response ──────────────────────────────────
protected void sendHtml(HttpExchange ex, int status, String html) throws IOException {
byte[] bytes = html.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8");
ex.sendResponseHeaders(status, bytes.length);
try (OutputStream os = ex.getResponseBody()) {
os.write(bytes);
}
}
protected void sendJson(HttpExchange ex, int status, String json) throws IOException {
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
ex.sendResponseHeaders(status, bytes.length);
try (OutputStream os = ex.getResponseBody()) {
os.write(bytes);
}
}
protected void sendRedirect(HttpExchange ex, String location) throws IOException {
ex.getResponseHeaders().set("Location", location);
ex.sendResponseHeaders(302, -1);
ex.getResponseBody().close();
}
// ─────────────────────────── Request-Parsing ───────────────────────────
protected Map<String, String> parseQuery(String query) {
Map<String, String> map = new HashMap<>();
if (query == null || query.isEmpty()) return map;
for (String pair : query.split("&")) {
String[] kv = pair.split("=", 2);
if (kv.length == 2) {
map.put(decode(kv[0]), decode(kv[1]));
} else if (kv.length == 1) {
map.put(decode(kv[0]), "");
}
}
return map;
}
protected Map<String, String> parseBody(HttpExchange ex) throws IOException {
try (InputStream is = ex.getRequestBody()) {
String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
return parseQuery(body);
}
}
protected Map<String, String> parseCookies(HttpExchange ex) {
Map<String, String> cookies = new HashMap<>();
String header = ex.getRequestHeaders().getFirst("Cookie");
if (header == null) return cookies;
for (String part : header.split(";")) {
String[] kv = part.trim().split("=", 2);
if (kv.length == 2) cookies.put(kv[0].trim(), kv[1].trim());
}
return cookies;
}
protected String getCookieToken(HttpExchange ex) {
return parseCookies(ex).get("ts_session");
}
protected void setSessionCookie(HttpExchange ex, String token, long timeoutMinutes) {
String cookie = "ts_session=" + token
+ "; Path=/"
+ "; HttpOnly"
+ "; Max-Age=" + (timeoutMinutes * 60);
ex.getResponseHeaders().add("Set-Cookie", cookie);
}
protected void clearSessionCookie(HttpExchange ex) {
ex.getResponseHeaders().add("Set-Cookie", "ts_session=; Path=/; HttpOnly; Max-Age=0");
}
// ─────────────────────────── Auth ──────────────────────────────────────
/**
* Gibt die aktuelle Session zurück oder null.
* Leitet NICHT automatisch um das macht der Aufrufer.
*/
protected WebSession getSession(HttpExchange ex, SessionManager sessionManager) {
String token = getCookieToken(ex);
return sessionManager.getSession(token);
}
/**
* Prüft ob eine gültige Session vorliegt.
* Bei ungültiger Session: Redirect zu /login, gibt null zurück.
*/
protected WebSession requireSession(HttpExchange ex, SessionManager sessionManager) throws IOException {
WebSession session = getSession(ex, sessionManager);
if (session == null) {
sendRedirect(ex, "/login");
return null;
}
return session;
}
/**
* Prüft ob Admin-Rolle vorhanden ist (legacy ohne i18n).
* Bei fehlender Rolle: 403-Fehler mit hart kodierten Texten.
*
* @deprecated Nutze {@link #requireAdmin(HttpExchange, WebSession, TicketPlugin)}.
*/
@Deprecated
protected boolean requireAdmin(HttpExchange ex, WebSession session) throws IOException {
if (!session.isAdmin()) {
sendHtml(ex, 403, errorPage("403 Kein Zugriff",
"Diese Seite ist nur für Administratoren zugänglich."));
return false;
}
return true;
}
/**
* Prüft ob Admin-Rolle vorhanden ist.
* Bei fehlender Rolle: 403-Fehler mit lokalisierten Texten aus der Sprachdatei.
*/
protected boolean requireAdmin(HttpExchange ex, WebSession session, TicketPlugin plugin) throws IOException {
if (!session.isAdmin()) {
sendHtml(ex, 403, errorPage(
wl(plugin, "error-403-title"),
wl(plugin, "error-403-message"),
plugin));
return false;
}
return true;
}
// ─────────────────────────── HTML-Hilfsmethoden ────────────────────────
/**
* Gibt das gemeinsame Seiten-Layout zurück (legacy ohne i18n).
* Nav-Texte und Rollenlabels sind hart kodiert (Deutsch).
*
* @deprecated Nutze {@link #layout(String, String, WebSession, TicketPlugin)}.
*/
@Deprecated
protected String layout(String title, String content, WebSession session) {
String username = session != null ? session.getUsername() : "";
String roleLabel = session != null ? (session.isAdmin() ? "Admin" : "Supporter") : "";
String roleClass = session != null ? (session.isAdmin() ? "admin" : "supporter") : "supporter";
String initial = username.isEmpty() ? "?" : String.valueOf(username.charAt(0)).toUpperCase();
String faqNav = session != null && session.isAdmin()
? "<a href='/faq' class='nav-link'>FAQ</a>" : "";
return buildLayoutHtml(title, content, session, username, roleLabel, roleClass, initial, faqNav,
"Dashboard", "Tickets", "Abmelden", "");
}
/**
* Gibt das gemeinsame Seiten-Layout zurück.
* Nav-Texte, Rollenlabels und Favicon werden aus Sprachdatei / Logo-Config gelesen.
*/
protected String layout(String title, String content, WebSession session, TicketPlugin plugin) {
String username = session != null ? session.getUsername() : "";
String roleLabel = session != null
? (session.isAdmin() ? wl(plugin, "role-admin") : wl(plugin, "role-supporter"))
: "";
String roleClass = session != null ? (session.isAdmin() ? "admin" : "supporter") : "supporter";
String initial = username.isEmpty() ? "?" : String.valueOf(username.charAt(0)).toUpperCase();
String faqNav = session != null && session.isAdmin()
? "<a href='/faq' class='nav-link'>" + escHtml(wl(plugin, "nav-faq")) + "</a>" : "";
return buildLayoutHtml(title, content, session, username, roleLabel, roleClass, initial, faqNav,
wl(plugin, "nav-dashboard"),
wl(plugin, "nav-tickets"),
wl(plugin, "nav-logout"),
buildFaviconHtml(plugin));
}
/** Interner Builder beide layout()-Varianten landen hier. */
private String buildLayoutHtml(String title, String content, WebSession session,
String username, String roleLabel, String roleClass,
String initial, String faqNav,
String navDashboard, String navTickets, String navLogout,
String faviconTag) {
return "<!DOCTYPE html>"
+ "<html lang='de'>"
+ "<head>"
+ "<meta charset='UTF-8'>"
+ "<meta name='viewport' content='width=device-width,initial-scale=1'>"
+ "<title>" + escHtml(title) + " TicketSystem Panel</title>"
+ faviconTag
+ "<link rel='preconnect' href='https://fonts.googleapis.com'>"
+ "<link href='https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap' rel='stylesheet'>"
+ "<link rel='stylesheet' href='/static/style.css'>"
+ "</head>"
+ "<body>"
+ "<nav class='navbar'>"
+ "<div class='nav-brand'>🎫 TicketSystem</div>"
+ "<div class='nav-links'>"
+ "<a href='/dashboard' class='nav-link'>" + escHtml(navDashboard) + "</a>"
+ "<a href='/tickets' class='nav-link'>" + escHtml(navTickets) + "</a>"
+ faqNav
+ "</div>"
+ "<div class='nav-user'>"
+ "<div class='nav-avatar'>" + escHtml(initial) + "</div>"
+ "<span class='nav-username'>" + escHtml(username) + "</span>"
+ "<span class='badge badge-" + roleClass + "'>" + escHtml(roleLabel) + "</span>"
+ "<a href='/logout' class='btn btn-sm btn-secondary'>" + escHtml(navLogout) + "</a>"
+ "</div>"
+ "</nav>"
+ "<main class='container'>"
+ content
+ "</main>"
+ "<script src='/static/panel.js'></script>"
+ "</body>"
+ "</html>";
}
/**
* Einfache Fehlerseite (legacy Zurück-Button auf Deutsch hart kodiert).
*
* @deprecated Nutze {@link #errorPage(String, String, TicketPlugin)}.
*/
@Deprecated
protected String errorPage(String title, String message) {
return """
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8">
<title>%s</title><link rel="stylesheet" href="/static/style.css"></head>
<body><div class="container" style="margin-top:4rem;text-align:center">
<h1>%s</h1><p>%s</p><a href="/dashboard" class="btn btn-primary">Zur\u00fcck</a>
</div></body></html>
""".formatted(title, title, message);
}
/**
* Einfache Fehlerseite mit lokalisiertem Zurück-Button.
*/
protected String errorPage(String title, String message, TicketPlugin plugin) {
return """
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8">
<title>%s</title><link rel="stylesheet" href="/static/style.css"></head>
<body><div class="container" style="margin-top:4rem;text-align:center">
<h1>%s</h1><p>%s</p><a href="/dashboard" class="btn btn-primary">%s</a>
</div></body></html>
""".formatted(title, title, message, wl(plugin, "btn-back"));
}
protected String escHtml(String s) {
if (s == null) return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
private String decode(String s) {
return URLDecoder.decode(s, StandardCharsets.UTF_8);
}
}