336 lines
15 KiB
Java
336 lines
15 KiB
Java
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("&", "&")
|
||
.replace("<", "<")
|
||
.replace(">", ">")
|
||
.replace("\"", """);
|
||
}
|
||
|
||
private String decode(String s) {
|
||
return URLDecoder.decode(s, StandardCharsets.UTF_8);
|
||
}
|
||
} |