commit 61ae38701e55dd6972fca32a4de9a006375261ab Author: Patrick <147879351+WinniePatGG@users.noreply.github.com> Date: Fri May 1 18:46:17 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..687e481 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.gradle +.idea +build +gradle +run +folia \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..51387e5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Patrick + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd3f1ce --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# MinePanel + +![MinePanel](https://img.shields.io/badge/MinePanel-Web%20Panel%20for%20Paper-4f8cff?style=for-the-badge) +![Java](https://img.shields.io/badge/Java-21-ff8a65?style=for-the-badge) +![Paper](https://img.shields.io/badge/Paper-1.21.x-58d68d?style=for-the-badge) +![Folia](https://img.shields.io/badge/Folia-Compatible-8e44ad?style=for-the-badge) +![Extensions](https://img.shields.io/badge/Extensions-Modular-9b59b6?style=for-the-badge) + +MinePanel is a Paper plugin that runs an embedded web panel for Minecraft server administration. + +The plugin is compatible with both Paper and Folia (1.21.x). + +The core plugin provides a secure web UI (login, roles, sessions, dashboard pages, logs, users, etc.) and an extension system so extra features can be added as separate jars. + +## What You Get + +### Core (works without extensions) + +- 🖥️ Embedded HTTP server for panel pages and API. +- 🔐 First-launch bootstrap flow to create the owner account. +- 🛡️ Secure auth with BCrypt + server-side sessions. +- 👥 Role/permission based panel access (`OWNER`, `ADMIN`, `VIEWER`). +- 📜 Console page with live panel log updates and command sending. +- 📊 Overview page with: + - TPS, memory, CPU cards + - TPS/Memory/CPU time-series charts + - Join/leave heatmaps (day x hour) +- 🎮 Players page with profile details and activity info. +- 🔌 Plugin and bans pages. +- 🎨 Themes page. +- 🧩 Extension management page. + +### Extension system + +- Core scans `plugins/MinePanel/extensions` on startup. +- If no extension jars are installed, you get the default panel only. +- If extension jars are present, their routes/tabs/features are loaded after restart. +- Extensions are discovered via Java `ServiceLoader` using: + - `META-INF/services/de.winniepat.minePanel.extensions.MinePanelExtension` +### All Extensions and their features are listed [here](https://github.com/WinniePatGG/MinePanel/tree/main/docs/AVAILABLE-EXTENSIONS.md) +## Runtime Configuration + +Default config in `src/main/resources/config.yml`: + +```yaml +web: + host: 127.0.0.1 + port: 8080 + sessionTtlMinutes: 120 + +security: + bootstrapTokenLength: 32 + +integrations: + github: + token: "" + releaseCacheSeconds: 300 +``` + +Use `integrations.github.token` (or environment variable `MINEPANEL_GITHUB_TOKEN`) for authenticated GitHub API requests and higher rate limits. + +## Build + +### Build core + extension jars + +```powershell +.\gradlew.bat assemble +``` + +### Build only core shadow jar + +```powershell +.\gradlew.bat shadowJar +``` + +## Build Outputs + +- Core plugin jar: + - `build/libs/MinePanel-.jar` +- Reports extension jar: + - `build/libs/extensions/MinePanel-Extension-Reports-.jar` +- Player-management extension jar: + - `build/libs/extensions/MinePanel-Extension-PlayerManagement-.jar` +- LuckPerms extension jar: + - `build/libs/extensions/MinePanel-Extension-LuckPerms-.jar` +- Player Stats extension jar: + - `build/libs/extensions/MinePanel-Extension-PlayerStats-.jar` + +## Installation + +1. Copy core jar to your server `plugins` folder. +2. Start server once. +3. Check console for the bootstrap token. +4. Open panel in browser: `http://127.0.0.1:8080` (or your configured host/port). +5. Complete setup and create owner account. + +## Installing Extensions + +1. Build or download extension jars. +2. Put them in: + - `plugins/MinePanel/extensions` +3. Restart the server. +4. New extension tabs/features become available. + +## Extension Links + +- GitHub releases: `https://github.com/WinniePatGG/MinePanel/releases` +- The panel reads extension assets from the latest selected channel (Release or Pre-release). +- If the GitHub API rate limit is reached, MinePanel falls back to cached release data and shows a warning in the Extensions tab. + +## Included Extension Artifacts in This Repo + +This repository can build two extension jars: + +- `MinePanel-Extension-Reports-*` + - Adds report system features. +- `MinePanel-Extension-PlayerManagement-*` + - Adds moderation/mute related player-management features. +- `MinePanel-Extension-LuckPerms-*` + - Adds LuckPerms player details in Players tab (groups, permissions, prefix/suffix). +- `MinePanel-Extension-PlayerStats-*` + - Adds player kills, deaths and economy balance (if Vault-compatible economy is present). + +## Writing Third-Party Extensions + +Detailed step-by-step guide: + +- `docs/EXTENSIONS.md` + +Implement `de.winniepat.minePanel.extensions.MinePanelExtension`. + +Typical flow: + +1. Implement extension class (`id`, `displayName`, lifecycle hooks). +2. Add service descriptor file: + - `META-INF/services/de.winniepat.minePanel.extensions.MinePanelExtension` +3. (Optional) Register panel routes via `registerWebRoutes(...)`. +4. (Optional) Add sidebar tabs via `navigationTabs()`. +5. (Optional) Register runtime commands via `ExtensionContext.commandRegistry()`. +6. Package jar and drop into `plugins/MinePanel/extensions`. + +## Security Notes + +- Use a reverse proxy + HTTPS in production. +- Restrict direct access to panel port (`web.port`) with firewall/network rules. +- Treat bootstrap tokens and owner credentials as sensitive secrets. + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..a1a8bdb --- /dev/null +++ b/build.gradle @@ -0,0 +1,316 @@ +plugins { + id 'java' + id("xyz.jpenilla.run-paper") version "2.3.1" + id 'com.gradleup.shadow' version '8.3.5' +} + +group = 'de.winniepat' +version = 'alpha-11' + +base { + archivesName = 'MinePanel' +} + +repositories { + mavenCentral() + maven { + name = "papermc-repo" + url = "https://repo.papermc.io/repository/maven-public/" + } + maven { + name = "luckperms-repo" + url = "https://repo.lucko.me/" + } +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") + compileOnly("net.luckperms:api:5.4") + implementation("com.sparkjava:spark-core:2.9.4") + implementation("org.xerial:sqlite-jdbc:3.46.1.3") + implementation("org.mindrot:jbcrypt:0.4") + implementation("com.google.code.gson:gson:2.11.0") +} + +tasks.named('shadowJar') { + archiveClassifier.set('') + exclude('de/winniepat/minePanel/extensions/reports/**') + exclude('de/winniepat/minePanel/extensions/playermanagement/**') + exclude('de/winniepat/minePanel/extensions/luckperms/**') + exclude('de/winniepat/minePanel/extensions/playerstats/**') + exclude('de/winniepat/minePanel/extensions/tickets/**') + exclude('de/winniepat/minePanel/extensions/worldbackups/**') + exclude('de/winniepat/minePanel/extensions/airstrike/**') + exclude('de/winniepat/minePanel/extensions/maintenance/**') + exclude('de/winniepat/minePanel/extensions/whitelist/**') + exclude('de/winniepat/minePanel/extensions/announcements/**') +} + +def extensionOutputDir = layout.buildDirectory.dir('libs/extensions') + +tasks.register('reportsExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel reports extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-Reports') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/reports/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.reports.ReportSystemExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('playerManagementExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel player-management extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-PlayerManagement') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/playermanagement/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.playermanagement.PlayerManagementExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('luckPermsExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel LuckPerms extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-LuckPerms') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/luckperms/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.luckperms.LuckPermsExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('playerStatsExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel player stats extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-PlayerStats') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/playerstats/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.playerstats.PlayerStatsExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('ticketsExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel ticket-system extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-Tickets') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/tickets/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.tickets.TicketSystemExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('worldBackupsExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel world-backups extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-WorldBackups') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/worldbackups/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.worldbackups.WorldBackupsExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('airstrikeExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel airstrike extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-Airstrike') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/airstrike/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.airstrike.AirstrikeExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('maintenanceExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel maintenance extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-Maintenance') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/maintenance/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.maintenance.MaintenanceExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('whitelistExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel whitelist extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-Whitelist') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/whitelist/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.whitelist.WhitelistExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('announcementsExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel announcements extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-Announcements') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/announcements/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.announcements.AnnouncementsExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('installExtensionsToRunServer', Copy) { + group = 'build' + description = 'Copies built extension jars into run/plugins/MinePanel/extensions for local testing.' + dependsOn( + tasks.named('reportsExtensionJar'), + tasks.named('playerManagementExtensionJar'), + tasks.named('luckPermsExtensionJar'), + tasks.named('playerStatsExtensionJar'), + tasks.named('ticketsExtensionJar'), + tasks.named('worldBackupsExtensionJar'), + tasks.named('airstrikeExtensionJar'), + tasks.named('maintenanceExtensionJar'), + tasks.named('whitelistExtensionJar'), + tasks.named('announcementsExtensionJar') + ) + + from(extensionOutputDir) + include('*.jar') + into(layout.projectDirectory.dir('run/plugins/MinePanel/extensions')) +} + +tasks.named('assemble') { + dependsOn(tasks.named('shadowJar')) + dependsOn(tasks.named('reportsExtensionJar')) + dependsOn(tasks.named('playerManagementExtensionJar')) + dependsOn(tasks.named('luckPermsExtensionJar')) + dependsOn(tasks.named('playerStatsExtensionJar')) + dependsOn(tasks.named('ticketsExtensionJar')) + dependsOn(tasks.named('worldBackupsExtensionJar')) + dependsOn(tasks.named('airstrikeExtensionJar')) + dependsOn(tasks.named('maintenanceExtensionJar')) + dependsOn(tasks.named('whitelistExtensionJar')) + dependsOn(tasks.named('announcementsExtensionJar')) +} + +tasks { + runServer { + minecraftVersion("1.21.11") + } +} + +def targetJavaVersion = 21 +java { + def javaVersion = JavaVersion.toVersion(targetJavaVersion) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + if (JavaVersion.current() < javaVersion) { + toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) + } +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + + if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) { + options.release.set(targetJavaVersion) + } +} + +processResources { + def props = [version: version] + inputs.properties props + filteringCharset 'UTF-8' + filesMatching('plugin.yml') { + expand props + } +} + +tasks.register('copyPlugin', Copy) { + dependsOn build + from("$buildDir/libs") + include('*.jar') + into("F:/MinePanel/folia/plugins") +} + +build.finalizedBy(copyPlugin) \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..e69de29 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..b740cf1 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/img/setup_token.png b/img/setup_token.png new file mode 100644 index 0000000..cfc1e45 Binary files /dev/null and b/img/setup_token.png differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..f49791b --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'MinePanel' diff --git a/src/main/java/de/winniepat/minePanel/MinePanel.java b/src/main/java/de/winniepat/minePanel/MinePanel.java new file mode 100644 index 0000000..f545c2b --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/MinePanel.java @@ -0,0 +1,212 @@ +package de.winniepat.minePanel; + +import de.winniepat.minePanel.auth.*; +import de.winniepat.minePanel.config.WebPanelConfig; +import de.winniepat.minePanel.extensions.*; +import de.winniepat.minePanel.integrations.*; +import de.winniepat.minePanel.lifecycle.PluginLifecycleSupport; +import de.winniepat.minePanel.logs.*; +import de.winniepat.minePanel.persistence.*; +import de.winniepat.minePanel.util.ServerSchedulerBridge; +import de.winniepat.minePanel.web.*; +import net.kyori.adventure.text.logger.slf4j.ComponentLogger; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.plugin.java.JavaPlugin; + +import java.nio.file.Path; +import java.time.Instant; + +public final class MinePanel extends JavaPlugin { + + private Database database; + private WebPanelServer webPanelServer; + private DiscordWebhookService discordWebhookService; + private LogRepository logRepository; + private PanelLogger panelLogger; + private PlayerActivityRepository playerActivityRepository; + private JoinLeaveEventRepository joinLeaveEventRepository; + private ExtensionManager extensionManager; + private ExtensionCommandRegistry extensionCommandRegistry; + private WebAssetService webAssetService; + private ExtensionSettingsRepository extensionSettingsRepository; + private ServerSchedulerBridge schedulerBridge; + + MiniMessage mm = MiniMessage.miniMessage(); + ComponentLogger componentLogger = this.getComponentLogger(); + + private record StartupContext( + UserRepository userRepository, + KnownPlayerRepository knownPlayerRepository, + SessionService sessionService, + PasswordHasher passwordHasher, + ServerLogService serverLogService, + BootstrapService bootstrapService, + JoinLeaveEventRepository joinLeaveEventRepository, + OAuthAccountRepository oAuthAccountRepository, + OAuthStateRepository oAuthStateRepository, + ExtensionManager extensionManager, + WebAssetService webAssetService, + ExtensionSettingsRepository extensionSettingsRepository + ) {} + + @Override + public void onEnable() { + saveDefaultConfig(); + PluginLifecycleSupport.configureThirdPartyStartupLogging(); + this.schedulerBridge = new ServerSchedulerBridge(this); + + WebPanelConfig panelConfig = WebPanelConfig.fromConfig(getConfig()); + StartupContext startupContext = initializeStartupContext(panelConfig); + + PluginLifecycleSupport.announceMinePanelBanner(this, componentLogger, mm); + PluginLifecycleSupport.announceBootstrapToken(startupContext.bootstrapService(), componentLogger, mm); + + PluginLifecycleSupport.registerPluginListeners( + this, + panelLogger, + startupContext.knownPlayerRepository(), + playerActivityRepository, + joinLeaveEventRepository + ); + PluginLifecycleSupport.synchronizeKnownPlayers(getServer(), startupContext.knownPlayerRepository(), playerActivityRepository); + startWebPanel(panelConfig, startupContext); + + componentLogger.info("{}{}:{}", mm.deserialize("MinePanel available at: "), "http://" + panelConfig.host(), panelConfig.port()); + panelLogger.log("SYSTEM", "PLUGIN", "MinePanel plugin started"); + } + + private StartupContext initializeStartupContext(WebPanelConfig panelConfig) { + this.database = new Database(getDataFolder().toPath().resolve("panel.db")); + this.database.initialize(); + + UserRepository userRepository = new UserRepository(database); + int normalizedOwners = userRepository.demoteExtraOwnersToAdmin(); + if (normalizedOwners > 0) { + getLogger().warning("Detected multiple owner accounts. Demoted " + normalizedOwners + " extra owner account(s) to ADMIN."); + } + + this.logRepository = new LogRepository(database); + KnownPlayerRepository knownPlayerRepository = new KnownPlayerRepository(database); + this.playerActivityRepository = new PlayerActivityRepository(database); + this.joinLeaveEventRepository = new JoinLeaveEventRepository(database); + + DiscordWebhookRepository discordWebhookRepository = new DiscordWebhookRepository(database); + this.discordWebhookService = new DiscordWebhookService(getLogger(), discordWebhookRepository); + this.panelLogger = new PanelLogger(logRepository, discordWebhookService); + this.logRepository.clearLogs(); + + SessionService sessionService = new SessionService(database, panelConfig.sessionTtlMinutes()); + PasswordHasher passwordHasher = new PasswordHasher(); + ServerLogService serverLogService = new ServerLogService(getDataFolder().toPath()); + BootstrapService bootstrapService = new BootstrapService(userRepository, panelConfig.bootstrapTokenLength()); + OAuthAccountRepository oAuthAccountRepository = new OAuthAccountRepository(database); + OAuthStateRepository oAuthStateRepository = new OAuthStateRepository(database); + this.extensionSettingsRepository = new ExtensionSettingsRepository(database); + this.webAssetService = initializeWebAssets(); + + this.extensionManager = initializeExtensions(knownPlayerRepository); + + return new StartupContext( + userRepository, + knownPlayerRepository, + sessionService, + passwordHasher, + serverLogService, + bootstrapService, + joinLeaveEventRepository, + oAuthAccountRepository, + oAuthStateRepository, + extensionManager, + webAssetService, + extensionSettingsRepository + ); + } + + private WebAssetService initializeWebAssets() { + WebAssetService service = new WebAssetService(this, getDataFolder().toPath().resolve("web")); + service.ensureSeeded(); + return service; + } + + private ExtensionManager initializeExtensions(KnownPlayerRepository knownPlayerRepository) { + this.extensionCommandRegistry = new BukkitExtensionCommandRegistry(this); + ExtensionContext context = new ExtensionContext( + this, + database, + panelLogger, + knownPlayerRepository, + playerActivityRepository, + extensionCommandRegistry, + schedulerBridge + ); + ExtensionManager manager = new ExtensionManager(this, context); + + + Path extensionDirectory = getDataFolder().toPath().resolve("extensions"); + manager.loadFromDirectory(extensionDirectory); + manager.enableAll(); + return manager; + } + + public ServerSchedulerBridge schedulerBridge() { + return schedulerBridge; + } + + private void startWebPanel(WebPanelConfig panelConfig, StartupContext startupContext) { + this.webPanelServer = new WebPanelServer( + this, + panelConfig, + startupContext.userRepository(), + startupContext.sessionService(), + startupContext.passwordHasher(), + this.logRepository, + startupContext.knownPlayerRepository(), + playerActivityRepository, + discordWebhookService, + panelLogger, + startupContext.serverLogService(), + startupContext.bootstrapService(), + startupContext.joinLeaveEventRepository(), + startupContext.oAuthAccountRepository(), + startupContext.oAuthStateRepository(), + startupContext.extensionManager(), + startupContext.webAssetService(), + startupContext.extensionSettingsRepository() + ); + this.webPanelServer.start(); + } + + @Override + public void onDisable() { + if (webPanelServer != null) { + webPanelServer.stop(); + } + + if (extensionManager != null) { + extensionManager.disableAll(); + } + + if (playerActivityRepository != null) { + long now = Instant.now().toEpochMilli(); + getServer().getOnlinePlayers().forEach(player -> + playerActivityRepository.onQuit(player.getUniqueId(), now) + ); + } + + if (panelLogger != null) { + panelLogger.log("SYSTEM", "PLUGIN", "MinePanel plugin stopping"); + } + + if (logRepository != null) { + PluginLifecycleSupport.exportPanelLogsToServerLogsDirectory(getDataFolder().toPath(), logRepository, getLogger()); + } + + if (discordWebhookService != null) { + discordWebhookService.shutdown(); + } + if (database != null) { + database.close(); + } + } + +} diff --git a/src/main/java/de/winniepat/minePanel/auth/PasswordHasher.java b/src/main/java/de/winniepat/minePanel/auth/PasswordHasher.java new file mode 100644 index 0000000..9ca59d0 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/auth/PasswordHasher.java @@ -0,0 +1,15 @@ +package de.winniepat.minePanel.auth; + +import org.mindrot.jbcrypt.BCrypt; + +public final class PasswordHasher { + + public String hash(String rawPassword) { + return BCrypt.hashpw(rawPassword, BCrypt.gensalt(12)); + } + + public boolean verify(String rawPassword, String hashedPassword) { + return BCrypt.checkpw(rawPassword, hashedPassword); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/auth/SessionService.java b/src/main/java/de/winniepat/minePanel/auth/SessionService.java new file mode 100644 index 0000000..e7a8bc5 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/auth/SessionService.java @@ -0,0 +1,108 @@ +package de.winniepat.minePanel.auth; + +import de.winniepat.minePanel.persistence.Database; + +import java.security.SecureRandom; +import java.sql.*; +import java.time.Instant; +import java.util.*; + +public final class SessionService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final Database database; + private final int sessionTtlMinutes; + + public SessionService(Database database, int sessionTtlMinutes) { + this.database = database; + this.sessionTtlMinutes = sessionTtlMinutes; + } + + public String createSession(long userId) { + cleanupExpiredSessions(); + + String token = generateToken(); + long now = Instant.now().toEpochMilli(); + long expiresAt = now + (sessionTtlMinutes * 60_000L); + + String sql = "INSERT INTO sessions(token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, token); + statement.setLong(2, userId); + statement.setLong(3, now); + statement.setLong(4, expiresAt); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not create session", exception); + } + return token; + } + + public Optional resolveUserId(String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + + long now = Instant.now().toEpochMilli(); + String sql = "SELECT user_id FROM sessions WHERE token = ? AND expires_at > ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, token); + statement.setLong(2, now); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return Optional.of(resultSet.getLong("user_id")); + } + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not resolve session", exception); + } + return Optional.empty(); + } + + public void deleteSession(String token) { + if (token == null || token.isBlank()) { + return; + } + + String sql = "DELETE FROM sessions WHERE token = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, token); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not delete session", exception); + } + } + + public void deleteSessionsByUserId(long userId) { + String sql = "DELETE FROM sessions WHERE user_id = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, userId); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not delete sessions for user", exception); + } + } + + public void cleanupExpiredSessions() { + String sql = "DELETE FROM sessions WHERE expires_at <= ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, Instant.now().toEpochMilli()); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not clean expired sessions", exception); + } + } + + private String generateToken() { + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/config/WebPanelConfig.java b/src/main/java/de/winniepat/minePanel/config/WebPanelConfig.java new file mode 100644 index 0000000..ceec608 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/config/WebPanelConfig.java @@ -0,0 +1,73 @@ +package de.winniepat.minePanel.config; + +import org.bukkit.configuration.file.FileConfiguration; + +public record WebPanelConfig( + String host, + int port, + int sessionTtlMinutes, + int bootstrapTokenLength, + int oauthStateTtlMinutes, + OAuthProviderConfig googleOAuth, + OAuthProviderConfig discordOAuth +) { + + public static WebPanelConfig fromConfig(FileConfiguration config) { + String host = config.getString("web.host", "127.0.0.1"); + int port = config.getInt("web.port", 8080); + int sessionTtlMinutes = Math.max(5, config.getInt("web.sessionTtlMinutes", 120)); + int bootstrapTokenLength = Math.max(16, config.getInt("security.bootstrapTokenLength", 32)); + int oauthStateTtlMinutes = Math.max(2, config.getInt("integrations.oauth.stateTtlMinutes", 10)); + + OAuthProviderConfig googleOAuth = parseProvider(config, "google", "MINEPANEL_OAUTH_GOOGLE"); + OAuthProviderConfig discordOAuth = parseProvider(config, "discord", "MINEPANEL_OAUTH_DISCORD"); + + return new WebPanelConfig(host, port, sessionTtlMinutes, bootstrapTokenLength, oauthStateTtlMinutes, googleOAuth, discordOAuth); + } + + private static OAuthProviderConfig parseProvider(FileConfiguration config, String key, String envPrefix) { + String section = "integrations.oauth." + key + "."; + boolean enabled = config.getBoolean(section + "enabled", false); + String clientId = envOrDefault(envPrefix + "_CLIENT_ID", config.getString(section + "clientId", "")); + String clientSecret = envOrDefault(envPrefix + "_CLIENT_SECRET", config.getString(section + "clientSecret", "")); + String redirectUri = envOrDefault(envPrefix + "_REDIRECT_URI", config.getString(section + "redirectUri", "")); + return new OAuthProviderConfig(enabled, sanitize(clientId), sanitize(clientSecret), sanitize(redirectUri)); + } + + private static String envOrDefault(String key, String fallback) { + String value = System.getenv(key); + if (value != null && !value.isBlank()) { + return value; + } + return fallback; + } + + private static String sanitize(String value) { + return value == null ? "" : value.trim(); + } + + public OAuthProviderConfig oauthProvider(String provider) { + if (provider == null) { + return OAuthProviderConfig.disabled(); + } + String normalized = provider.trim().toLowerCase(); + if ("google".equals(normalized)) { + return googleOAuth; + } + if ("discord".equals(normalized)) { + return discordOAuth; + } + return OAuthProviderConfig.disabled(); + } + + public record OAuthProviderConfig(boolean enabled, String clientId, String clientSecret, String redirectUri) { + public static OAuthProviderConfig disabled() { + return new OAuthProviderConfig(false, "", "", ""); + } + + public boolean configured() { + return enabled && !clientId.isBlank() && !clientSecret.isBlank() && !redirectUri.isBlank(); + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/BukkitExtensionCommandRegistry.java b/src/main/java/de/winniepat/minePanel/extensions/BukkitExtensionCommandRegistry.java new file mode 100644 index 0000000..0e17b94 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/BukkitExtensionCommandRegistry.java @@ -0,0 +1,200 @@ +package de.winniepat.minePanel.extensions; + +import de.winniepat.minePanel.MinePanel; +import org.bukkit.command.*; +import org.bukkit.plugin.Plugin; +import org.bukkit.command.PluginIdentifiableCommand; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.*; + +public final class BukkitExtensionCommandRegistry implements ExtensionCommandRegistry { + + private final MinePanel plugin; + private final Map> commandsByExtensionId = new HashMap<>(); + + public BukkitExtensionCommandRegistry(MinePanel plugin) { + this.plugin = plugin; + } + + @Override + public synchronized boolean register( + String extensionId, + String name, + String description, + String usage, + String permission, + List aliases, + CommandExecutor executor, + TabCompleter tabCompleter + ) { + if (executor == null || isBlank(extensionId) || isBlank(name)) { + return false; + } + + String normalizedExtensionId = extensionId.trim().toLowerCase(Locale.ROOT); + String normalizedName = name.trim().toLowerCase(Locale.ROOT); + + CommandMap commandMap = getCommandMap(); + if (commandMap == null) { + plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command map unavailable"); + return false; + } + + if (commandMap.getCommand(normalizedName) != null) { + plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command already exists"); + return false; + } + + RuntimeExtensionCommand command = new RuntimeExtensionCommand( + plugin, + normalizedName, + defaultString(description, "Extension command"), + defaultString(usage, "/" + normalizedName), + aliases == null ? List.of() : aliases, + executor, + tabCompleter + ); + if (!isBlank(permission)) { + command.setPermission(permission.trim()); + } + + boolean registered = commandMap.register("minepanelext", command); + if (!registered) { + plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command map rejected registration"); + return false; + } + + commandsByExtensionId.computeIfAbsent(normalizedExtensionId, ignored -> new ArrayList<>()).add(command); + return true; + } + + @Override + public synchronized void unregisterForExtension(String extensionId) { + if (isBlank(extensionId)) { + return; + } + + String normalizedExtensionId = extensionId.trim().toLowerCase(Locale.ROOT); + List commands = commandsByExtensionId.remove(normalizedExtensionId); + if (commands == null || commands.isEmpty()) { + return; + } + + CommandMap commandMap = getCommandMap(); + if (commandMap == null) { + return; + } + + Map knownCommands = getKnownCommands(commandMap); + for (RuntimeExtensionCommand command : commands) { + command.unregister(commandMap); + removeCommandEntries(knownCommands, command); + } + } + + @Override + public synchronized void unregisterAll() { + List extensionIds = new ArrayList<>(commandsByExtensionId.keySet()); + for (String extensionId : extensionIds) { + unregisterForExtension(extensionId); + } + commandsByExtensionId.clear(); + } + + private void removeCommandEntries(Map knownCommands, RuntimeExtensionCommand command) { + if (knownCommands == null || knownCommands.isEmpty()) { + return; + } + + Iterator> iterator = knownCommands.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (entry.getValue() == command) { + iterator.remove(); + } + } + } + + @SuppressWarnings("unchecked") + private Map getKnownCommands(CommandMap commandMap) { + try { + Field knownCommandsField = commandMap.getClass().getDeclaredField("knownCommands"); + knownCommandsField.setAccessible(true); + Object value = knownCommandsField.get(commandMap); + if (value instanceof Map map) { + return (Map) map; + } + } catch (Exception ignored) { + // Best effort cleanup. + } + return Collections.emptyMap(); + } + + private CommandMap getCommandMap() { + try { + Method getCommandMapMethod = plugin.getServer().getClass().getMethod("getCommandMap"); + Object value = getCommandMapMethod.invoke(plugin.getServer()); + if (value instanceof CommandMap map) { + return map; + } + } catch (Exception ignored) { + // Supported on CraftServer-based implementations. + } + return null; + } + + private String defaultString(String value, String fallback) { + return isBlank(value) ? fallback : value.trim(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private static final class RuntimeExtensionCommand extends Command implements PluginIdentifiableCommand { + + private final Plugin plugin; + private final CommandExecutor executor; + private final TabCompleter tabCompleter; + + private RuntimeExtensionCommand( + Plugin plugin, + String name, + String description, + String usage, + List aliases, + CommandExecutor executor, + TabCompleter tabCompleter + ) { + super(name, description, usage, aliases == null ? List.of() : aliases); + this.plugin = plugin; + this.executor = executor; + this.tabCompleter = tabCompleter; + } + + @Override + public boolean execute(CommandSender sender, String commandLabel, String[] args) { + if (testPermission(sender)) { + return executor.onCommand(sender, this, commandLabel, args); + } + return true; + } + + @Override + public List tabComplete(CommandSender sender, String alias, String[] args) { + if (tabCompleter == null) { + return List.of(); + } + List completions = tabCompleter.onTabComplete(sender, this, alias, args); + return completions == null ? List.of() : completions; + } + + @Override + public Plugin getPlugin() { + return plugin; + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionCommandRegistry.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionCommandRegistry.java new file mode 100644 index 0000000..db152fb --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionCommandRegistry.java @@ -0,0 +1,37 @@ +package de.winniepat.minePanel.extensions; + +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.TabCompleter; + +import java.util.List; + +public interface ExtensionCommandRegistry { + + boolean register( + String extensionId, + String name, + String description, + String usage, + String permission, + List aliases, + CommandExecutor executor, + TabCompleter tabCompleter + ); + + default boolean register( + String extensionId, + String name, + String description, + String usage, + String permission, + List aliases, + CommandExecutor executor + ) { + return register(extensionId, name, description, usage, permission, aliases, executor, null); + } + + void unregisterForExtension(String extensionId); + + void unregisterAll(); +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionConfigurable.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionConfigurable.java new file mode 100644 index 0000000..90e50d9 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionConfigurable.java @@ -0,0 +1,15 @@ +package de.winniepat.minePanel.extensions; + +/** + * Optional extension contract for runtime settings updates from the panel. + */ +public interface ExtensionConfigurable { + + /** + * Applies updated settings JSON immediately at runtime. + * + * @param settingsJson extension settings JSON payload + */ + void onSettingsUpdated(String settingsJson); +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionContext.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionContext.java new file mode 100644 index 0000000..01e7e83 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionContext.java @@ -0,0 +1,20 @@ +package de.winniepat.minePanel.extensions; + +import de.winniepat.minePanel.MinePanel; +import de.winniepat.minePanel.logs.PanelLogger; +import de.winniepat.minePanel.persistence.Database; +import de.winniepat.minePanel.persistence.KnownPlayerRepository; +import de.winniepat.minePanel.persistence.PlayerActivityRepository; +import de.winniepat.minePanel.util.ServerSchedulerBridge; + +public record ExtensionContext( + MinePanel plugin, + Database database, + PanelLogger panelLogger, + KnownPlayerRepository knownPlayerRepository, + PlayerActivityRepository playerActivityRepository, + ExtensionCommandRegistry commandRegistry, + ServerSchedulerBridge schedulerBridge +) { +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionManager.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionManager.java new file mode 100644 index 0000000..741ee61 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionManager.java @@ -0,0 +1,566 @@ +package de.winniepat.minePanel.extensions; + +import de.winniepat.minePanel.MinePanel; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Stream; + +public final class ExtensionManager { + + private final MinePanel plugin; + private final ExtensionContext context; + private final List loadedExtensions = new ArrayList<>(); + private final List classLoaders = new ArrayList<>(); + private final Set loadedIds = new HashSet<>(); + private final Set loadedArtifacts = new HashSet<>(); + private final Map extensionSourceById = new HashMap<>(); + private Path extensionsDirectory; + private ExtensionWebRegistry activeWebRegistry; + + public ExtensionManager(MinePanel plugin, ExtensionContext context) { + this.plugin = plugin; + this.context = context; + } + + public void registerBuiltIn(MinePanelExtension extension) { + registerLoadedExtension(extension, "built-in"); + } + + public synchronized void loadFromDirectory(Path extensionsDirectory) { + this.extensionsDirectory = extensionsDirectory; + try { + Files.createDirectories(extensionsDirectory); + } catch (IOException exception) { + plugin.getLogger().warning("Could not create extensions directory: " + exception.getMessage()); + return; + } + + cleanupDuplicateArtifactsOnStartup(extensionsDirectory); + + try (Stream files = Files.list(extensionsDirectory)) { + files + .filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) + .sorted(this::compareJarsNewestFirst) + .forEach(path -> loadJarExtensions(path, null)); + } catch (IOException exception) { + plugin.getLogger().warning("Could not scan extensions directory: " + exception.getMessage()); + } + } + + public synchronized ReloadResult reloadNewFromDirectory(ExtensionWebRegistry webRegistry) { + if (webRegistry != null) { + this.activeWebRegistry = webRegistry; + } + + if (extensionsDirectory == null) { + return new ReloadResult(0, List.of(), List.of("extensions_directory_unavailable")); + } + + List newlyLoaded = new ArrayList<>(); + List warnings = new ArrayList<>(); + int scanned = 0; + + try (Stream files = Files.list(extensionsDirectory)) { + List jars = files + .filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) + .sorted(this::compareJarsNewestFirst) + .toList(); + + for (Path jarFile : jars) { + scanned++; + String artifactName = jarFile.getFileName().toString(); + if (loadedArtifacts.contains(artifactName)) { + continue; + } + loadJarExtensions(jarFile, newlyLoaded); + } + } catch (IOException exception) { + warnings.add("could_not_scan_extensions_directory"); + plugin.getLogger().warning("Could not scan extensions directory: " + exception.getMessage()); + } + + for (MinePanelExtension extension : newlyLoaded) { + try { + extension.onEnable(); + plugin.getLogger().info("Enabled extension " + extension.id() + " (" + extension.displayName() + ")"); + } catch (Exception exception) { + warnings.add("enable_failed:" + extension.id()); + context.commandRegistry().unregisterForExtension(extension.id()); + plugin.getLogger().warning("Could not enable extension " + extension.id() + ": " + exception.getMessage()); + } + + if (activeWebRegistry != null) { + try { + extension.registerWebRoutes(activeWebRegistry); + } catch (Exception exception) { + warnings.add("route_registration_failed:" + extension.id()); + plugin.getLogger().warning("Could not register web routes for extension " + extension.id() + ": " + exception.getMessage()); + } + } + } + + List loadedExtensionIds = newlyLoaded.stream().map(MinePanelExtension::id).toList(); + return new ReloadResult(scanned, loadedExtensionIds, warnings); + } + + public synchronized List> installedExtensions() { + List> installed = new ArrayList<>(); + for (MinePanelExtension extension : loadedExtensions) { + String id = extension.id() == null ? "" : extension.id(); + String source = extensionSourceById.getOrDefault(id.toLowerCase(Locale.ROOT), "unknown"); + installed.add(Map.of( + "id", id, + "displayName", extension.displayName() == null ? id : extension.displayName(), + "source", source + )); + } + + installed.sort(Comparator.comparing(item -> String.valueOf(item.get("id")), String.CASE_INSENSITIVE_ORDER)); + return installed; + } + + public synchronized List> availableArtifacts() { + List artifacts = scanArtifactFileNames(); + Set loadedArtifacts = new HashSet<>(); + for (String source : extensionSourceById.values()) { + if (source != null && source.toLowerCase(Locale.ROOT).endsWith(".jar")) { + loadedArtifacts.add(source); + } + } + + List> available = new ArrayList<>(); + for (String artifact : artifacts) { + available.add(Map.of( + "fileName", artifact, + "loaded", loadedArtifacts.contains(artifact) + )); + } + return available; + } + + public void enableAll() { + for (MinePanelExtension extension : loadedExtensions) { + try { + extension.onEnable(); + plugin.getLogger().info("Enabled extension " + extension.id() + " (" + extension.displayName() + ")"); + } catch (Exception exception) { + context.commandRegistry().unregisterForExtension(extension.id()); + plugin.getLogger().warning("Could not enable extension " + extension.id() + ": " + exception.getMessage()); + } + } + } + + public synchronized void registerWebRoutes(ExtensionWebRegistry webRegistry) { + this.activeWebRegistry = webRegistry; + for (MinePanelExtension extension : loadedExtensions) { + try { + extension.registerWebRoutes(webRegistry); + } catch (Exception exception) { + plugin.getLogger().warning("Could not register web routes for extension " + extension.id() + ": " + exception.getMessage()); + } + } + } + + public List navigationTabs() { + List tabs = new ArrayList<>(); + for (MinePanelExtension extension : loadedExtensions) { + try { + tabs.addAll(extension.navigationTabs()); + } catch (Exception exception) { + plugin.getLogger().warning("Could not read tabs for extension " + extension.id() + ": " + exception.getMessage()); + } + } + return tabs; + } + + public synchronized boolean supportsSettings(String extensionId) { + String normalizedId = normalizeExtensionId(extensionId); + if (normalizedId.isBlank()) { + return false; + } + + for (MinePanelExtension extension : loadedExtensions) { + if (!normalizeExtensionId(extension.id()).equals(normalizedId)) { + continue; + } + return extension instanceof ExtensionConfigurable; + } + + return false; + } + + public synchronized boolean applySettings(String extensionId, String settingsJson) { + String normalizedId = normalizeExtensionId(extensionId); + if (normalizedId.isBlank()) { + return false; + } + + for (MinePanelExtension extension : loadedExtensions) { + if (!normalizeExtensionId(extension.id()).equals(normalizedId)) { + continue; + } + + if (!(extension instanceof ExtensionConfigurable configurable)) { + return false; + } + + try { + configurable.onSettingsUpdated(settingsJson == null ? "{}" : settingsJson); + return true; + } catch (Exception exception) { + plugin.getLogger().warning("Could not apply live settings for extension " + extension.id() + ": " + exception.getMessage()); + return false; + } + } + + return false; + } + + public synchronized void disableAll() { + ListIterator iterator = loadedExtensions.listIterator(loadedExtensions.size()); + while (iterator.hasPrevious()) { + MinePanelExtension extension = iterator.previous(); + try { + extension.onDisable(); + } catch (Exception exception) { + plugin.getLogger().warning("Could not disable extension " + extension.id() + ": " + exception.getMessage()); + } finally { + context.commandRegistry().unregisterForExtension(extension.id()); + } + } + + context.commandRegistry().unregisterAll(); + + for (URLClassLoader classLoader : classLoaders) { + try { + classLoader.close(); + } catch (IOException ignored) { + // Best effort cleanup. + } + } + classLoaders.clear(); + loadedArtifacts.clear(); + loadedIds.clear(); + extensionSourceById.clear(); + loadedExtensions.clear(); + } + + private void loadJarExtensions(Path jarFile, List newlyLoaded) { + URLClassLoader classLoader = null; + try { + URL url = jarFile.toUri().toURL(); + classLoader = new URLClassLoader(new URL[]{url}, plugin.getClass().getClassLoader()); + + ServiceLoader serviceLoader = ServiceLoader.load(MinePanelExtension.class, classLoader); + int found = 0; + int registered = 0; + for (MinePanelExtension extension : serviceLoader) { + found++; + if (registerLoadedExtension(extension, jarFile.getFileName().toString())) { + registered++; + if (newlyLoaded != null) { + newlyLoaded.add(extension); + } + } + } + + if (found == 0) { + plugin.getLogger().warning("No MinePanel extension entry found in " + jarFile.getFileName() + ". Add META-INF/services/" + MinePanelExtension.class.getName()); + } + + if (registered > 0) { + classLoaders.add(classLoader); + loadedArtifacts.add(jarFile.getFileName().toString()); + classLoader = null; + } + } catch (Exception exception) { + plugin.getLogger().warning("Could not load extension jar " + jarFile.getFileName() + ": " + exception.getMessage()); + } finally { + if (classLoader != null) { + try { + classLoader.close(); + } catch (IOException ignored) { + // Best effort cleanup. + } + } + } + } + + private boolean registerLoadedExtension(MinePanelExtension extension, String source) { + if (extension == null || extension.id() == null || extension.id().isBlank()) { + plugin.getLogger().warning("Skipping extension with invalid id from " + source); + return false; + } + + String id = extension.id().trim().toLowerCase(Locale.ROOT); + if (!loadedIds.add(id)) { + plugin.getLogger().warning("Skipping duplicate extension id: " + extension.id()); + return false; + } + + try { + extension.onLoad(context); + loadedExtensions.add(extension); + extensionSourceById.put(id, source); + plugin.getLogger().info("Loaded extension " + extension.id() + " from " + source); + return true; + } catch (Exception exception) { + loadedIds.remove(id); + plugin.getLogger().warning("Could not initialize extension " + extension.id() + ": " + exception.getMessage()); + return false; + } + } + + public record ReloadResult(int scannedArtifactCount, List loadedExtensionIds, List warnings) { + } + + private List scanArtifactFileNames() { + if (extensionsDirectory == null) { + return List.of(); + } + + try (Stream files = Files.list(extensionsDirectory)) { + return files + .filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) + .map(path -> path.getFileName().toString()) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } catch (IOException exception) { + plugin.getLogger().warning("Could not read extension artifacts: " + exception.getMessage()); + return List.of(); + } + } + + private int compareJarsNewestFirst(Path left, Path right) { + long leftModified = lastModifiedMillis(left); + long rightModified = lastModifiedMillis(right); + int byModified = Long.compare(rightModified, leftModified); + if (byModified != 0) { + return byModified; + } + + String leftName = left.getFileName().toString().toLowerCase(Locale.ROOT); + String rightName = right.getFileName().toString().toLowerCase(Locale.ROOT); + return rightName.compareTo(leftName); + } + + private long lastModifiedMillis(Path file) { + try { + return Files.getLastModifiedTime(file).toMillis(); + } catch (IOException ignored) { + return 0L; + } + } + + private void cleanupDuplicateArtifactsOnStartup(Path extensionDirectory) { + List jars; + try (Stream files = Files.list(extensionDirectory)) { + jars = files + .filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) + .toList(); + } catch (IOException exception) { + plugin.getLogger().warning("Could not inspect extension artifacts for startup cleanup: " + exception.getMessage()); + return; + } + + if (jars.size() < 2) { + return; + } + + Map keepByKey = new HashMap<>(); + Map keepVersionByKey = new HashMap<>(); + + for (Path jar : jars) { + String fileName = jar.getFileName().toString(); + String key = extensionKeyFromArtifact(fileName); + if (key.isBlank()) { + continue; + } + + ArtifactVersionToken currentVersion = parseArtifactVersion(fileName); + Path keptPath = keepByKey.get(key); + ArtifactVersionToken keptVersion = keepVersionByKey.get(key); + + if (keptPath == null || keptVersion == null) { + keepByKey.put(key, jar); + keepVersionByKey.put(key, currentVersion); + continue; + } + + int compare = compareArtifactVersions(currentVersion, keptVersion); + if (compare > 0 || (compare == 0 && compareJarsNewestFirst(jar, keptPath) < 0)) { + keepByKey.put(key, jar); + keepVersionByKey.put(key, currentVersion); + } + } + + for (Path jar : jars) { + String fileName = jar.getFileName().toString(); + String key = extensionKeyFromArtifact(fileName); + if (key.isBlank()) { + continue; + } + + Path kept = keepByKey.get(key); + if (kept == null || kept.equals(jar)) { + continue; + } + + try { + Files.deleteIfExists(jar); + plugin.getLogger().info("Removed old extension artifact on startup: " + fileName + " (kept " + kept.getFileName() + ")"); + } catch (IOException exception) { + plugin.getLogger().warning("Could not delete old extension artifact " + fileName + " on startup: " + exception.getMessage()); + } + } + } + + private String extensionKeyFromArtifact(String fileName) { + if (fileName == null || fileName.isBlank()) { + return ""; + } + + String normalized = fileName.trim().toLowerCase(Locale.ROOT); + if (!normalized.endsWith(".jar") || !normalized.startsWith("minepanel-extension-")) { + return ""; + } + + normalized = normalized.substring(0, normalized.length() - 4); + normalized = normalized.substring("minepanel-extension-".length()); + + int alphaSplit = normalized.lastIndexOf("-alpha-"); + if (alphaSplit > 0) { + normalized = normalized.substring(0, alphaSplit); + } else { + int semverSplit = normalized.lastIndexOf('-'); + if (semverSplit > 0) { + String suffix = normalized.substring(semverSplit + 1); + if (isNumericVersionSuffix(suffix)) { + normalized = normalized.substring(0, semverSplit); + } + } + } + + return normalized.replaceAll("[^a-z0-9]", ""); + } + + private String normalizeExtensionId(String rawId) { + if (rawId == null || rawId.isBlank()) { + return ""; + } + + return rawId.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + } + + private ArtifactVersionToken parseArtifactVersion(String fileName) { + if (fileName == null || fileName.isBlank()) { + return ArtifactVersionToken.unknown(); + } + + String normalized = fileName.trim().toLowerCase(Locale.ROOT); + if (normalized.endsWith(".jar")) { + normalized = normalized.substring(0, normalized.length() - 4); + } + + int alphaSplit = normalized.lastIndexOf("-alpha-"); + if (alphaSplit > 0) { + String buildRaw = normalized.substring(alphaSplit + "-alpha-".length()); + try { + return ArtifactVersionToken.alpha(Integer.parseInt(buildRaw)); + } catch (NumberFormatException ignored) { + return ArtifactVersionToken.unknown(); + } + } + + int semverSplit = normalized.lastIndexOf('-'); + if (semverSplit > 0) { + String suffix = normalized.substring(semverSplit + 1); + if (isNumericVersionSuffix(suffix)) { + List parts = new ArrayList<>(); + for (String part : suffix.split("\\.")) { + if (part.isBlank()) { + continue; + } + try { + parts.add(Integer.parseInt(part)); + } catch (NumberFormatException ignored) { + return ArtifactVersionToken.unknown(); + } + } + if (!parts.isEmpty()) { + return ArtifactVersionToken.semver(parts); + } + } + } + + return ArtifactVersionToken.unknown(); + } + + private boolean isNumericVersionSuffix(String value) { + if (value == null || value.isBlank()) { + return false; + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (!Character.isDigit(c) && c != '.') { + return false; + } + } + return true; + } + + private int compareArtifactVersions(ArtifactVersionToken left, ArtifactVersionToken right) { + if (left.kind() != right.kind()) { + return Integer.compare(left.kind().priority, right.kind().priority); + } + + if (left.kind() == ArtifactVersionKind.ALPHA) { + return Integer.compare(left.alphaBuild(), right.alphaBuild()); + } + + if (left.kind() == ArtifactVersionKind.SEMVER) { + int max = Math.max(left.semverParts().size(), right.semverParts().size()); + for (int i = 0; i < max; i++) { + int l = i < left.semverParts().size() ? left.semverParts().get(i) : 0; + int r = i < right.semverParts().size() ? right.semverParts().get(i) : 0; + if (l != r) { + return Integer.compare(l, r); + } + } + } + + return 0; + } + + private enum ArtifactVersionKind { + UNKNOWN(0), + ALPHA(1), + SEMVER(2); + + private final int priority; + + ArtifactVersionKind(int priority) { + this.priority = priority; + } + } + + private record ArtifactVersionToken(ArtifactVersionKind kind, int alphaBuild, List semverParts) { + private static ArtifactVersionToken unknown() { + return new ArtifactVersionToken(ArtifactVersionKind.UNKNOWN, -1, List.of()); + } + + private static ArtifactVersionToken alpha(int build) { + return new ArtifactVersionToken(ArtifactVersionKind.ALPHA, build, List.of()); + } + + private static ArtifactVersionToken semver(List parts) { + return new ArtifactVersionToken(ArtifactVersionKind.SEMVER, -1, parts == null ? List.of() : List.copyOf(parts)); + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionNavigationTab.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionNavigationTab.java new file mode 100644 index 0000000..32e38f5 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionNavigationTab.java @@ -0,0 +1,9 @@ +package de.winniepat.minePanel.extensions; + +public record ExtensionNavigationTab( + String category, + String label, + String path +) { +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionRouteHandler.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionRouteHandler.java new file mode 100644 index 0000000..28ec6b3 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionRouteHandler.java @@ -0,0 +1,11 @@ +package de.winniepat.minePanel.extensions; + +import de.winniepat.minePanel.users.PanelUser; +import spark.Request; +import spark.Response; + +@FunctionalInterface +public interface ExtensionRouteHandler { + Object handle(Request request, Response response, PanelUser user) throws Exception; +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionWebRegistry.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionWebRegistry.java new file mode 100644 index 0000000..f31ea3a --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionWebRegistry.java @@ -0,0 +1,16 @@ +package de.winniepat.minePanel.extensions; + +import de.winniepat.minePanel.users.PanelPermission; +import spark.Response; + +import java.util.Map; + +public interface ExtensionWebRegistry { + + void get(String path, PanelPermission permission, ExtensionRouteHandler handler); + + void post(String path, PanelPermission permission, ExtensionRouteHandler handler); + + String json(Response response, int status, Map payload); +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/MinePanelExtension.java b/src/main/java/de/winniepat/minePanel/extensions/MinePanelExtension.java new file mode 100644 index 0000000..1b97435 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/MinePanelExtension.java @@ -0,0 +1,88 @@ +package de.winniepat.minePanel.extensions; + +import java.util.List; + +/** + * Service Provider Interface (SPI) for MinePanel extensions. + *

+ * Implementations are discovered through Java {@code ServiceLoader} and loaded by the + * extension manager during MinePanel startup. + *

+ *

+ * Typical lifecycle: + *

+ *
    + *
  1. {@link #onLoad(ExtensionContext)}
  2. + *
  3. {@link #onEnable()}
  4. + *
  5. {@link #registerWebRoutes(ExtensionWebRegistry)}
  6. + *
  7. {@link #navigationTabs()}
  8. + *
  9. {@link #onDisable()} on shutdown
  10. + *
+ */ +public interface MinePanelExtension { + + /** + * Returns the stable, unique id of this extension. + *

+ * The id is used for runtime bookkeeping and should remain unchanged across versions. + *

+ * + * @return extension id (for example {@code "reports"}) + */ + String id(); + + /** + * Returns the human-readable display name shown in UI/status views. + * + * @return extension display name + */ + String displayName(); + + /** + * Called once when the extension is loaded and before it is enabled. + *

+ * Use this for initialization that requires MinePanel services, such as setting up + * database schema or caching dependencies from the provided context. + *

+ * + * @param context runtime context provided by MinePanel + */ + default void onLoad(ExtensionContext context) { + } + + /** + * Called after {@link #onLoad(ExtensionContext)} when the extension should become active. + *

+ * Use this for registering listeners, commands, or background tasks. + *

+ */ + default void onEnable() { + } + + /** + * Called when MinePanel is disabling the extension. + *

+ * Use this hook to release resources and stop tasks started in {@link #onEnable()}. + *

+ */ + default void onDisable() { + } + + /** + * Allows the extension to register HTTP routes in the MinePanel web server. + * + * @param webRegistry route registration facade + */ + default void registerWebRoutes(ExtensionWebRegistry webRegistry) { + } + + /** + * Returns sidebar navigation tabs contributed by this extension. + * + * @return list of extension tabs, or an empty list if none are contributed + */ + default List navigationTabs() { + return List.of(); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/airstrike/AirstrikeExtension.java b/src/main/java/de/winniepat/minePanel/extensions/airstrike/AirstrikeExtension.java new file mode 100644 index 0000000..db38bf1 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/airstrike/AirstrikeExtension.java @@ -0,0 +1,233 @@ +package de.winniepat.minePanel.extensions.airstrike; + +import com.google.gson.Gson; +import de.winniepat.minePanel.extensions.ExtensionContext; +import de.winniepat.minePanel.extensions.ExtensionWebRegistry; +import de.winniepat.minePanel.extensions.MinePanelExtension; +import de.winniepat.minePanel.persistence.KnownPlayer; +import de.winniepat.minePanel.users.PanelPermission; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.entity.TNTPrimed; +import org.bukkit.util.Vector; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public final class AirstrikeExtension implements MinePanelExtension { + + private static final int DEFAULT_TNT = 5000; + private static final int MAX_TNT = 20000; + private static final int WAVE_COUNT = 20; + private static final long WAVE_INTERVAL_TICKS = 12L; + private static final double DROP_HEIGHT = 52.0; + private static final double HORIZONTAL_SPREAD = 5.0; + + private final Gson gson = new Gson(); + private ExtensionContext context; + + @Override + public String id() { + return "airstrike"; + } + + @Override + public String displayName() { + return "Airstrike"; + } + + @Override + public void onLoad(ExtensionContext context) { + this.context = context; + } + + @Override + public void registerWebRoutes(ExtensionWebRegistry webRegistry) { + webRegistry.post("/api/extensions/airstrike/launch", PanelPermission.MANAGE_AIRSTRIKE, (request, response, user) -> { + AirstrikePayload payload = gson.fromJson(request.body(), AirstrikePayload.class); + if (payload == null || (isBlank(payload.uuid()) && isBlank(payload.username()))) { + return webRegistry.json(response, 400, Map.of("error", "invalid_payload")); + } + + int amount = sanitizeAmount(payload.amount()); + + TargetSnapshot target; + try { + target = context.schedulerBridge().callGlobal( + () -> resolveOnlineTarget(payload.uuid(), payload.username()), + 2, + TimeUnit.SECONDS + ); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return webRegistry.json(response, 500, Map.of("error", "interrupted")); + } catch (ExecutionException | TimeoutException exception) { + return webRegistry.json(response, 500, Map.of("error", "lookup_failed")); + } + + if (target == null) { + return webRegistry.json(response, 404, Map.of("error", "player_not_online")); + } + + try { + launchAirstrike(target.uuid(), target.username(), user.username(), amount); + } catch (IllegalStateException exception) { + return webRegistry.json(response, 500, Map.of("error", "airstrike_schedule_failed")); + } + + return webRegistry.json(response, 200, Map.of( + "ok", true, + "targetUuid", target.uuid().toString(), + "targetUsername", target.username(), + "tntCount", amount + )); + }); + } + + private void launchAirstrike(UUID targetUuid, String targetName, String actorName, int totalTnt) { + context.panelLogger().log("AUDIT", actorName, "Triggered airstrike on " + targetName + " with " + totalTnt + " TNT"); + + final de.winniepat.minePanel.util.ServerSchedulerBridge.CancellableTask[] repeatingTask = new de.winniepat.minePanel.util.ServerSchedulerBridge.CancellableTask[1]; + + Runnable waveTask = new Runnable() { + private int remainingTnt = totalTnt; + private int remainingWaves = WAVE_COUNT; + + @Override + public void run() { + Player onlineTarget = context.plugin().getServer().getPlayer(targetUuid); + if (onlineTarget == null || !onlineTarget.isOnline()) { + context.panelLogger().log("SYSTEM", "airstrike", "Airstrike cancelled because target went offline: " + targetName); + if (repeatingTask[0] != null) { + repeatingTask[0].cancel(); + } + return; + } + + int tntThisWave = (int) Math.ceil((double) remainingTnt / Math.max(1, remainingWaves)); + boolean dispatched = runOnPlayerScheduler(onlineTarget, () -> { + for (int index = 0; index < tntThisWave; index++) { + spawnStrikeTnt(onlineTarget); + } + }); + + if (!dispatched) { + context.panelLogger().log("SYSTEM", "airstrike", "Airstrike cancelled because player scheduler dispatch failed: " + targetName); + if (repeatingTask[0] != null) { + repeatingTask[0].cancel(); + } + return; + } + + remainingTnt = Math.max(0, remainingTnt - tntThisWave); + + remainingWaves--; + + if (remainingWaves <= 0 || remainingTnt <= 0) { + if (repeatingTask[0] != null) { + repeatingTask[0].cancel(); + } + } + } + }; + + repeatingTask[0] = context.schedulerBridge().runRepeatingGlobal(waveTask, 0L, WAVE_INTERVAL_TICKS); + } + + private boolean runOnPlayerScheduler(Player player, Runnable task) { + if (!context.schedulerBridge().isFolia()) { + task.run(); + return true; + } + + try { + Object entityScheduler = player.getClass().getMethod("getScheduler").invoke(player); + Method runMethod = entityScheduler.getClass().getMethod("run", org.bukkit.plugin.Plugin.class, java.util.function.Consumer.class, Runnable.class); + runMethod.invoke(entityScheduler, context.plugin(), (java.util.function.Consumer) ignored -> task.run(), null); + return true; + } catch (ReflectiveOperationException exception) { + return false; + } + } + + private void spawnStrikeTnt(Player target) { + ThreadLocalRandom random = ThreadLocalRandom.current(); + Location targetLocation = target.getLocation(); + Vector velocity = target.getVelocity(); + // Lead the strike slightly so waves keep up with sprinting players. + double leadFactor = 6.0; + double predictedX = targetLocation.getX() + (velocity.getX() * leadFactor); + double predictedZ = targetLocation.getZ() + (velocity.getZ() * leadFactor); + + double angle = random.nextDouble(0.0, Math.PI * 2.0); + double radius = random.nextDouble(0.0, HORIZONTAL_SPREAD); + double offsetX = Math.cos(angle) * radius; + double offsetZ = Math.sin(angle) * radius; + + Location strikeCenter = new Location(targetLocation.getWorld(), predictedX, targetLocation.getY(), predictedZ); + Location spawnLocation = strikeCenter.add(offsetX, DROP_HEIGHT + random.nextDouble(0.0, 8.0), offsetZ); + TNTPrimed tnt = targetLocation.getWorld().spawn(spawnLocation, TNTPrimed.class); + tnt.setFuseTicks(65 + random.nextInt(20)); + + Vector tntVelocity = new Vector( + random.nextDouble(-0.08, 0.08), + -1.85 - random.nextDouble(0.0, 0.45), + random.nextDouble(-0.08, 0.08) + ); + tnt.setVelocity(tntVelocity); + } + + private TargetSnapshot resolveOnlineTarget(String rawUuid, String rawUsername) { + if (!isBlank(rawUuid)) { + try { + Player online = context.plugin().getServer().getPlayer(UUID.fromString(rawUuid.trim())); + if (online != null) { + return new TargetSnapshot(online.getUniqueId(), online.getName()); + } + } catch (IllegalArgumentException ignored) { + // Try username fallback. + } + } + + if (!isBlank(rawUsername)) { + Player online = context.plugin().getServer().getPlayerExact(rawUsername.trim()); + if (online != null) { + return new TargetSnapshot(online.getUniqueId(), online.getName()); + } + + Optional known = context.knownPlayerRepository().findByUsername(rawUsername.trim()); + if (known.isPresent()) { + Player knownOnline = context.plugin().getServer().getPlayer(known.get().uuid()); + if (knownOnline != null) { + return new TargetSnapshot(knownOnline.getUniqueId(), knownOnline.getName()); + } + } + } + + return null; + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private int sanitizeAmount(Integer requestedAmount) { + if (requestedAmount == null) { + return DEFAULT_TNT; + } + return Math.max(1, Math.min(MAX_TNT, requestedAmount)); + } + + private record AirstrikePayload(String uuid, String username, Integer amount) { + } + + private record TargetSnapshot(UUID uuid, String username) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/announcements/Announcement.java b/src/main/java/de/winniepat/minePanel/extensions/announcements/Announcement.java new file mode 100644 index 0000000..339ba68 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/announcements/Announcement.java @@ -0,0 +1,12 @@ +package de.winniepat.minePanel.extensions.announcements; + +public record Announcement( + long id, + String message, + boolean enabled, + int sortOrder, + long createdAt, + long updatedAt +) { +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/announcements/AnnouncementRepository.java b/src/main/java/de/winniepat/minePanel/extensions/announcements/AnnouncementRepository.java new file mode 100644 index 0000000..ba647ac --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/announcements/AnnouncementRepository.java @@ -0,0 +1,160 @@ +package de.winniepat.minePanel.extensions.announcements; + +import de.winniepat.minePanel.persistence.Database; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +public final class AnnouncementRepository { + + private final Database database; + + public AnnouncementRepository(Database database) { + this.database = database; + } + + public void initializeSchema() { + try (Connection connection = database.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE IF NOT EXISTS ext_announcements (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "message TEXT NOT NULL," + + "enabled INTEGER NOT NULL DEFAULT 1," + + "sort_order INTEGER NOT NULL DEFAULT 0," + + "created_at INTEGER NOT NULL," + + "updated_at INTEGER NOT NULL" + + ")"); + + statement.execute("CREATE TABLE IF NOT EXISTS ext_announcements_config (" + + "id INTEGER PRIMARY KEY," + + "enabled INTEGER NOT NULL DEFAULT 0," + + "interval_seconds INTEGER NOT NULL DEFAULT 300," + + "updated_at INTEGER NOT NULL DEFAULT 0" + + ")"); + + statement.execute("INSERT OR IGNORE INTO ext_announcements_config(id, enabled, interval_seconds, updated_at) VALUES (1, 0, 300, 0)"); + } catch (SQLException exception) { + throw new IllegalStateException("Could not initialize announcements schema", exception); + } + } + + public AnnouncementConfig readConfig() { + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT enabled, interval_seconds, updated_at FROM ext_announcements_config WHERE id = 1"); + ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return new AnnouncementConfig(false, 300, 0L); + } + + return new AnnouncementConfig( + resultSet.getInt("enabled") == 1, + Math.max(10, resultSet.getInt("interval_seconds")), + resultSet.getLong("updated_at") + ); + } catch (SQLException exception) { + throw new IllegalStateException("Could not read announcements config", exception); + } + } + + public void saveConfig(boolean enabled, int intervalSeconds, long updatedAt) { + int normalizedInterval = Math.max(10, Math.min(86_400, intervalSeconds)); + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement( + "UPDATE ext_announcements_config SET enabled = ?, interval_seconds = ?, updated_at = ? WHERE id = 1" + )) { + statement.setInt(1, enabled ? 1 : 0); + statement.setInt(2, normalizedInterval); + statement.setLong(3, Math.max(0L, updatedAt)); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not save announcements config", exception); + } + } + + public List listMessages() { + List messages = new ArrayList<>(); + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement( + "SELECT id, message, enabled, sort_order, created_at, updated_at FROM ext_announcements ORDER BY sort_order ASC, id ASC" + ); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + messages.add(new Announcement( + resultSet.getLong("id"), + resultSet.getString("message"), + resultSet.getInt("enabled") == 1, + resultSet.getInt("sort_order"), + resultSet.getLong("created_at"), + resultSet.getLong("updated_at") + )); + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not list announcements", exception); + } + return messages; + } + + public long createMessage(String message, long now) { + int nextSortOrder = nextSortOrder(); + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO ext_announcements(message, enabled, sort_order, created_at, updated_at) VALUES (?, 1, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + )) { + statement.setString(1, message); + statement.setInt(2, nextSortOrder); + statement.setLong(3, now); + statement.setLong(4, now); + statement.executeUpdate(); + + try (ResultSet resultSet = statement.getGeneratedKeys()) { + if (resultSet.next()) { + return resultSet.getLong(1); + } + } + return -1L; + } catch (SQLException exception) { + throw new IllegalStateException("Could not create announcement", exception); + } + } + + public boolean deleteMessage(long id) { + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement("DELETE FROM ext_announcements WHERE id = ?")) { + statement.setLong(1, id); + return statement.executeUpdate() > 0; + } catch (SQLException exception) { + throw new IllegalStateException("Could not delete announcement", exception); + } + } + + public boolean setMessageEnabled(long id, boolean enabled, long updatedAt) { + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement("UPDATE ext_announcements SET enabled = ?, updated_at = ? WHERE id = ?")) { + statement.setInt(1, enabled ? 1 : 0); + statement.setLong(2, updatedAt); + statement.setLong(3, id); + return statement.executeUpdate() > 0; + } catch (SQLException exception) { + throw new IllegalStateException("Could not update announcement", exception); + } + } + + private int nextSortOrder() { + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT COALESCE(MAX(sort_order), 0) + 1 AS next_order FROM ext_announcements"); + ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return resultSet.getInt("next_order"); + } + return 1; + } catch (SQLException exception) { + throw new IllegalStateException("Could not compute announcement sort order", exception); + } + } + + public record AnnouncementConfig(boolean enabled, int intervalSeconds, long updatedAt) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/announcements/AnnouncementService.java b/src/main/java/de/winniepat/minePanel/extensions/announcements/AnnouncementService.java new file mode 100644 index 0000000..8f8cf17 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/announcements/AnnouncementService.java @@ -0,0 +1,188 @@ +package de.winniepat.minePanel.extensions.announcements; + +import de.winniepat.minePanel.extensions.ExtensionContext; +import org.bukkit.entity.Player; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; + +public final class AnnouncementService { + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss"); + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final ExtensionContext context; + private final AnnouncementRepository repository; + + private volatile boolean enabled; + private volatile int intervalSeconds; + private volatile long nextRunAt; + private int rotationCursor; + private de.winniepat.minePanel.util.ServerSchedulerBridge.CancellableTask task; + + public AnnouncementService(ExtensionContext context, AnnouncementRepository repository) { + this.context = context; + this.repository = repository; + + AnnouncementRepository.AnnouncementConfig config = repository.readConfig(); + this.enabled = config.enabled(); + this.intervalSeconds = Math.max(10, config.intervalSeconds()); + this.nextRunAt = System.currentTimeMillis() + (long) this.intervalSeconds * 1000L; + this.rotationCursor = 0; + } + + public void start() { + stop(); + this.task = context.schedulerBridge().runRepeatingGlobal(this::tick, 20L, 20L); + } + + public void stop() { + if (task != null) { + task.cancel(); + task = null; + } + } + + public AnnouncementState state() { + return new AnnouncementState(enabled, intervalSeconds, nextRunAt, repository.listMessages()); + } + + public void updateConfig(boolean enabled, int intervalSeconds) { + this.enabled = enabled; + this.intervalSeconds = Math.max(10, Math.min(86_400, intervalSeconds)); + this.nextRunAt = System.currentTimeMillis() + (long) this.intervalSeconds * 1000L; + repository.saveConfig(this.enabled, this.intervalSeconds, System.currentTimeMillis()); + } + + public boolean sendNow() { + Announcement next = nextAnnouncement(); + if (next == null) { + return false; + } + + broadcast(next.message()); + nextRunAt = System.currentTimeMillis() + (long) intervalSeconds * 1000L; + return true; + } + + public long addMessage(String message) { + long id = repository.createMessage(message, System.currentTimeMillis()); + if (id > 0) { + nextRunAt = System.currentTimeMillis() + (long) intervalSeconds * 1000L; + } + return id; + } + + public boolean deleteMessage(long id) { + boolean deleted = repository.deleteMessage(id); + if (deleted) { + rotationCursor = 0; + } + return deleted; + } + + public boolean setMessageEnabled(long id, boolean enabled) { + boolean updated = repository.setMessageEnabled(id, enabled, System.currentTimeMillis()); + if (updated) { + rotationCursor = 0; + } + return updated; + } + + private void tick() { + if (!enabled) { + return; + } + + long now = System.currentTimeMillis(); + if (now < nextRunAt) { + return; + } + + Announcement next = nextAnnouncement(); + if (next == null) { + nextRunAt = now + (long) intervalSeconds * 1000L; + return; + } + + broadcast(next.message()); + nextRunAt = now + (long) intervalSeconds * 1000L; + } + + private Announcement nextAnnouncement() { + List enabledMessages = repository.listMessages().stream() + .filter(Announcement::enabled) + .toList(); + + if (enabledMessages.isEmpty()) { + rotationCursor = 0; + return null; + } + + if (rotationCursor >= enabledMessages.size()) { + rotationCursor = 0; + } + + Announcement selected = enabledMessages.get(rotationCursor); + rotationCursor = (rotationCursor + 1) % enabledMessages.size(); + return selected; + } + + private void broadcast(String message) { + long nowMillis = System.currentTimeMillis(); + List onlinePlayers = List.copyOf(context.plugin().getServer().getOnlinePlayers()); + + for (Player onlinePlayer : onlinePlayers) { + String resolved = resolvePlaceholders(message, onlinePlayer, nowMillis, onlinePlayers.size()); + onlinePlayer.sendMessage(resolved); + } + + context.panelLogger().log("SYSTEM", "ANNOUNCEMENTS", "Broadcasted announcement: " + message); + } + + private String resolvePlaceholders(String template, Player player, long nowMillis, int onlineCount) { + if (template == null || template.isBlank()) { + return ""; + } + + ZoneId zoneId = ZoneId.systemDefault(); + var now = Instant.ofEpochMilli(nowMillis).atZone(zoneId); + double tps = readPrimaryTps(); + + String resolved = template; + resolved = resolved.replace("%player%", player == null ? "Player" : player.getName()); + resolved = resolved.replace("%time%", TIME_FORMAT.format(now)); + resolved = resolved.replace("%date%", DATE_FORMAT.format(now)); + resolved = resolved.replace("%datetime%", DATETIME_FORMAT.format(now)); + resolved = resolved.replace("%online%", String.valueOf(onlineCount)); + resolved = resolved.replace("%max_players%", String.valueOf(context.plugin().getServer().getMaxPlayers())); + resolved = resolved.replace("%world%", player == null || player.getWorld() == null ? "unknown" : player.getWorld().getName()); + resolved = resolved.replace("%server%", context.plugin().getServer().getName()); + resolved = resolved.replace("%tps%", tps < 0 ? "N/A" : String.format(Locale.US, "%.2f", tps)); + return resolved; + } + + private double readPrimaryTps() { + try { + Object result = context.plugin().getServer().getClass().getMethod("getTPS").invoke(context.plugin().getServer()); + if (result instanceof double[] values && values.length > 0) { + return values[0]; + } + } catch (Exception ignored) { + // Keep placeholder available on implementations without Paper TPS API. + } + return -1.0; + } + + public record AnnouncementState( + boolean enabled, + int intervalSeconds, + long nextRunAt, + List messages + ) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/announcements/AnnouncementsExtension.java b/src/main/java/de/winniepat/minePanel/extensions/announcements/AnnouncementsExtension.java new file mode 100644 index 0000000..1231af1 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/announcements/AnnouncementsExtension.java @@ -0,0 +1,173 @@ +package de.winniepat.minePanel.extensions.announcements; + +import com.google.gson.Gson; +import de.winniepat.minePanel.extensions.ExtensionContext; +import de.winniepat.minePanel.extensions.ExtensionNavigationTab; +import de.winniepat.minePanel.extensions.ExtensionWebRegistry; +import de.winniepat.minePanel.extensions.MinePanelExtension; +import de.winniepat.minePanel.users.PanelPermission; + +import java.util.List; +import java.util.Map; + +public final class AnnouncementsExtension implements MinePanelExtension { + + private final Gson gson = new Gson(); + + private ExtensionContext context; + private AnnouncementService service; + + @Override + public String id() { + return "announcements"; + } + + @Override + public String displayName() { + return "Announcements"; + } + + @Override + public void onLoad(ExtensionContext context) { + this.context = context; + AnnouncementRepository repository = new AnnouncementRepository(context.database()); + repository.initializeSchema(); + this.service = new AnnouncementService(context, repository); + } + + @Override + public void onEnable() { + service.start(); + } + + @Override + public void onDisable() { + if (service != null) { + service.stop(); + } + } + + @Override + public void registerWebRoutes(ExtensionWebRegistry webRegistry) { + webRegistry.get("/api/extensions/announcements", PanelPermission.VIEW_ANNOUNCEMENTS, (request, response, user) -> { + AnnouncementService.AnnouncementState state = service.state(); + List> messages = state.messages().stream() + .map(message -> Map.of( + "id", message.id(), + "message", message.message(), + "enabled", message.enabled(), + "sortOrder", message.sortOrder(), + "createdAt", message.createdAt(), + "updatedAt", message.updatedAt() + )) + .toList(); + + return webRegistry.json(response, 200, Map.of( + "enabled", state.enabled(), + "intervalSeconds", state.intervalSeconds(), + "nextRunAt", state.nextRunAt(), + "messages", messages + )); + }); + + webRegistry.post("/api/extensions/announcements/config", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> { + ConfigPayload payload = gson.fromJson(request.body(), ConfigPayload.class); + if (payload == null || payload.enabled() == null || payload.intervalSeconds() == null) { + return webRegistry.json(response, 400, Map.of("error", "invalid_payload")); + } + + service.updateConfig(payload.enabled(), payload.intervalSeconds()); + context.panelLogger().log("AUDIT", user.username(), "Updated announcements configuration"); + return webRegistry.json(response, 200, Map.of("ok", true)); + }); + + webRegistry.post("/api/extensions/announcements/messages", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> { + MessagePayload payload = gson.fromJson(request.body(), MessagePayload.class); + String message = payload == null ? "" : sanitizeMessage(payload.message()); + if (message.isBlank()) { + return webRegistry.json(response, 400, Map.of("error", "invalid_message")); + } + + long id = service.addMessage(message); + if (id <= 0) { + return webRegistry.json(response, 500, Map.of("error", "create_failed")); + } + + context.panelLogger().log("AUDIT", user.username(), "Added announcement #" + id); + return webRegistry.json(response, 201, Map.of("ok", true, "id", id)); + }); + + webRegistry.post("/api/extensions/announcements/messages/:id/delete", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> { + long id = parseId(request.params("id")); + if (id <= 0) { + return webRegistry.json(response, 400, Map.of("error", "invalid_message_id")); + } + + if (!service.deleteMessage(id)) { + return webRegistry.json(response, 404, Map.of("error", "message_not_found")); + } + + context.panelLogger().log("AUDIT", user.username(), "Deleted announcement #" + id); + return webRegistry.json(response, 200, Map.of("ok", true)); + }); + + webRegistry.post("/api/extensions/announcements/messages/:id/toggle", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> { + long id = parseId(request.params("id")); + TogglePayload payload = gson.fromJson(request.body(), TogglePayload.class); + if (id <= 0 || payload == null || payload.enabled() == null) { + return webRegistry.json(response, 400, Map.of("error", "invalid_payload")); + } + + if (!service.setMessageEnabled(id, payload.enabled())) { + return webRegistry.json(response, 404, Map.of("error", "message_not_found")); + } + + context.panelLogger().log("AUDIT", user.username(), "Set announcement #" + id + " enabled=" + payload.enabled()); + return webRegistry.json(response, 200, Map.of("ok", true)); + }); + + webRegistry.post("/api/extensions/announcements/send-now", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> { + if (!service.sendNow()) { + return webRegistry.json(response, 400, Map.of("error", "no_enabled_messages")); + } + + context.panelLogger().log("AUDIT", user.username(), "Sent announcement manually"); + return webRegistry.json(response, 200, Map.of("ok", true)); + }); + } + + @Override + public List navigationTabs() { + return List.of(new ExtensionNavigationTab("server", "Announcements", "/dashboard/announcements")); + } + + private long parseId(String raw) { + try { + return Long.parseLong(raw); + } catch (Exception ignored) { + return -1L; + } + } + + private String sanitizeMessage(String raw) { + if (raw == null) { + return ""; + } + + String trimmed = raw.trim(); + if (trimmed.length() > 220) { + return trimmed.substring(0, 220); + } + return trimmed; + } + + private record ConfigPayload(Boolean enabled, Integer intervalSeconds) { + } + + private record MessagePayload(String message) { + } + + private record TogglePayload(Boolean enabled) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/luckperms/LuckPermsExtension.java b/src/main/java/de/winniepat/minePanel/extensions/luckperms/LuckPermsExtension.java new file mode 100644 index 0000000..1f3ee78 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/luckperms/LuckPermsExtension.java @@ -0,0 +1,108 @@ +package de.winniepat.minePanel.extensions.luckperms; + +import de.winniepat.minePanel.extensions.*; +import de.winniepat.minePanel.users.PanelPermission; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.LuckPermsProvider; +import net.luckperms.api.cacheddata.CachedMetaData; +import net.luckperms.api.model.group.Group; +import net.luckperms.api.model.user.User; +import net.luckperms.api.query.QueryOptions; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +public final class LuckPermsExtension implements MinePanelExtension { + + private ExtensionContext context; + + @Override + public String id() { + return "luckperms"; + } + + @Override + public String displayName() { + return "LuckPerms Integration"; + } + + @Override + public void onLoad(ExtensionContext context) { + this.context = context; + } + + @Override + public void registerWebRoutes(ExtensionWebRegistry webRegistry) { + webRegistry.get("/api/extensions/luckperms/player/:uuid", PanelPermission.VIEW_LUCKPERMS, (request, response, user) -> { + UUID playerUuid; + try { + playerUuid = UUID.fromString(request.params("uuid")); + } catch (IllegalArgumentException exception) { + return webRegistry.json(response, 400, Map.of("error", "invalid_uuid")); + } + + if (!isLuckPermsAvailable()) { + return webRegistry.json(response, 200, Map.of( + "available", false, + "error", "luckperms_not_present" + )); + } + + try { + LuckPerms api = LuckPermsProvider.get(); + User lpUser = api.getUserManager().loadUser(playerUuid).get(3, TimeUnit.SECONDS); + QueryOptions queryOptions = api.getContextManager() + .getQueryOptions(lpUser) + .orElse(api.getContextManager().getStaticQueryOptions()); + + CachedMetaData metaData = lpUser.getCachedData().getMetaData(queryOptions); + String prefix = sanitize(metaData.getPrefix()); + String suffix = sanitize(metaData.getSuffix()); + + List groups = lpUser.getInheritedGroups(queryOptions).stream() + .map(Group::getName) + .distinct() + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + + List permissions = lpUser.getCachedData().getPermissionData(queryOptions).getPermissionMap().entrySet().stream() + .filter(entry -> Boolean.TRUE.equals(entry.getValue())) + .map(Map.Entry::getKey) + .distinct() + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + + List limitedPermissions = permissions.stream().limit(200).toList(); + boolean permissionsTruncated = permissions.size() > limitedPermissions.size(); + + Map payload = new HashMap<>(); + payload.put("available", true); + payload.put("primaryGroup", sanitize(lpUser.getPrimaryGroup())); + payload.put("prefix", prefix); + payload.put("suffix", suffix); + payload.put("groups", groups); + payload.put("permissions", limitedPermissions); + payload.put("permissionsCount", permissions.size()); + payload.put("permissionsTruncated", permissionsTruncated); + payload.put("generatedAt", System.currentTimeMillis()); + return webRegistry.json(response, 200, payload); + } catch (Exception exception) { + return webRegistry.json(response, 500, Map.of( + "available", false, + "error", "luckperms_lookup_failed", + "details", exception.getClass().getSimpleName() + )); + } + }); + } + + private boolean isLuckPermsAvailable() { + return context.plugin().getServer().getPluginManager().getPlugin("LuckPerms") != null; + } + + private String sanitize(String value) { + return value == null || value.isBlank() ? "-" : value; + } +} + + diff --git a/src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceExtension.java b/src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceExtension.java new file mode 100644 index 0000000..b39772b --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceExtension.java @@ -0,0 +1,141 @@ +package de.winniepat.minePanel.extensions.maintenance; + +import com.google.gson.Gson; +import de.winniepat.minePanel.extensions.ExtensionContext; +import de.winniepat.minePanel.extensions.ExtensionNavigationTab; +import de.winniepat.minePanel.extensions.ExtensionWebRegistry; +import de.winniepat.minePanel.extensions.MinePanelExtension; +import de.winniepat.minePanel.users.PanelPermission; +import org.bukkit.event.HandlerList; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class MaintenanceExtension implements MinePanelExtension { + + private final Gson gson = new Gson(); + + private ExtensionContext context; + private MaintenanceService maintenanceService; + private MaintenanceJoinListener joinListener; + private MaintenanceMotdListener motdListener; + + @Override + public String id() { + return "maintenance"; + } + + @Override + public String displayName() { + return "Maintenance"; + } + + @Override + public void onLoad(ExtensionContext context) { + this.context = context; + this.maintenanceService = new MaintenanceService(context); + } + + @Override + public void onEnable() { + this.joinListener = new MaintenanceJoinListener(maintenanceService); + this.motdListener = new MaintenanceMotdListener(maintenanceService); + context.plugin().getServer().getPluginManager().registerEvents(joinListener, context.plugin()); + context.plugin().getServer().getPluginManager().registerEvents(motdListener, context.plugin()); + } + + @Override + public void onDisable() { + if (joinListener != null) { + HandlerList.unregisterAll(joinListener); + joinListener = null; + } + if (motdListener != null) { + HandlerList.unregisterAll(motdListener); + motdListener = null; + } + } + + @Override + public void registerWebRoutes(ExtensionWebRegistry webRegistry) { + webRegistry.get("/api/extensions/maintenance/status", PanelPermission.VIEW_MAINTENANCE, (request, response, user) -> { + MaintenanceService.MaintenanceSnapshot snapshot = maintenanceService.snapshot(); + return webRegistry.json(response, 200, Map.of( + "enabled", snapshot.enabled(), + "reason", snapshot.reason(), + "motd", snapshot.motd(), + "changedBy", snapshot.changedBy(), + "changedAt", snapshot.changedAt(), + "affectedOnlinePlayers", snapshot.affectedOnlinePlayers(), + "bypassPermission", MaintenanceService.BYPASS_PERMISSION + )); + }); + + webRegistry.post("/api/extensions/maintenance/enable", PanelPermission.MANAGE_MAINTENANCE, (request, response, user) -> { + TogglePayload payload = gson.fromJson(request.body(), TogglePayload.class); + String reason = payload != null ? payload.reason() : null; + String motd = payload != null ? payload.motd() : null; + boolean kickNonStaff = payload != null && Boolean.TRUE.equals(payload.kickNonStaff()); + + int kicked; + try { + kicked = maintenanceService.enable(user.username(), reason, motd, kickNonStaff); + } catch (IllegalStateException exception) { + return webRegistry.json(response, 500, Map.of("error", "maintenance_enable_failed", "details", exception.getMessage())); + } + context.panelLogger().log( + "AUDIT", + user.username(), + "Enabled maintenance mode" + (kickNonStaff ? " and kicked " + kicked + " player(s)" : "") + ); + + return webRegistry.json(response, 200, toStatusPayload(true, kicked)); + }); + + webRegistry.post("/api/extensions/maintenance/disable", PanelPermission.MANAGE_MAINTENANCE, (request, response, user) -> { + maintenanceService.disable(user.username()); + context.panelLogger().log("AUDIT", user.username(), "Disabled maintenance mode"); + return webRegistry.json(response, 200, toStatusPayload(true, 0)); + }); + + webRegistry.post("/api/extensions/maintenance/kick-nonstaff", PanelPermission.MANAGE_MAINTENANCE, (request, response, user) -> { + if (!maintenanceService.isEnabled()) { + return webRegistry.json(response, 400, Map.of("error", "maintenance_not_enabled")); + } + + int kicked; + try { + kicked = maintenanceService.kickNonStaffPlayers(); + } catch (IllegalStateException exception) { + return webRegistry.json(response, 500, Map.of("error", "maintenance_kick_failed", "details", exception.getMessage())); + } + context.panelLogger().log("AUDIT", user.username(), "Kicked " + kicked + " non-staff player(s) during maintenance"); + return webRegistry.json(response, 200, toStatusPayload(true, kicked)); + }); + } + + @Override + public List navigationTabs() { + return List.of(new ExtensionNavigationTab("server", "Maintenance", "/dashboard/maintenance")); + } + + private Map toStatusPayload(boolean ok, int kicked) { + MaintenanceService.MaintenanceSnapshot snapshot = maintenanceService.snapshot(); + Map payload = new HashMap<>(); + payload.put("ok", ok); + payload.put("enabled", snapshot.enabled()); + payload.put("reason", snapshot.reason()); + payload.put("motd", snapshot.motd()); + payload.put("changedBy", snapshot.changedBy()); + payload.put("changedAt", snapshot.changedAt()); + payload.put("affectedOnlinePlayers", snapshot.affectedOnlinePlayers()); + payload.put("kicked", kicked); + payload.put("bypassPermission", MaintenanceService.BYPASS_PERMISSION); + return payload; + } + + private record TogglePayload(String reason, String motd, Boolean kickNonStaff) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceJoinListener.java b/src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceJoinListener.java new file mode 100644 index 0000000..295fe82 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceJoinListener.java @@ -0,0 +1,29 @@ +package de.winniepat.minePanel.extensions.maintenance; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerLoginEvent; + +public final class MaintenanceJoinListener implements Listener { + + private final MaintenanceService maintenanceService; + + public MaintenanceJoinListener(MaintenanceService maintenanceService) { + this.maintenanceService = maintenanceService; + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onPlayerLogin(PlayerLoginEvent event) { + if (!maintenanceService.isEnabled()) { + return; + } + + if (maintenanceService.canBypass(event.getPlayer())) { + return; + } + + event.disallow(PlayerLoginEvent.Result.KICK_OTHER, maintenanceService.kickMessage()); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceMotdListener.java b/src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceMotdListener.java new file mode 100644 index 0000000..52f050b --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceMotdListener.java @@ -0,0 +1,25 @@ +package de.winniepat.minePanel.extensions.maintenance; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.server.ServerListPingEvent; + +public final class MaintenanceMotdListener implements Listener { + + private final MaintenanceService maintenanceService; + + public MaintenanceMotdListener(MaintenanceService maintenanceService) { + this.maintenanceService = maintenanceService; + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onServerListPing(ServerListPingEvent event) { + if (!maintenanceService.isEnabled()) { + return; + } + + event.setMotd(maintenanceService.motd()); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceService.java b/src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceService.java new file mode 100644 index 0000000..4abaf0d --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceService.java @@ -0,0 +1,157 @@ +package de.winniepat.minePanel.extensions.maintenance; + +import de.winniepat.minePanel.extensions.ExtensionContext; +import org.bukkit.entity.Player; + +import java.time.Instant; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public final class MaintenanceService { + + public static final String BYPASS_PERMISSION = "minepanel.maintenance.bypass"; + + private final ExtensionContext context; + + private volatile boolean enabled; + private volatile String reason = "§cServer maintenance in progress"; + private volatile String motd = "§cMaintenance mode is active"; + private volatile String changedBy = "SYSTEM"; + private volatile long changedAt = Instant.now().toEpochMilli(); + + public MaintenanceService(ExtensionContext context) { + this.context = context; + } + + public MaintenanceSnapshot snapshot() { + return new MaintenanceSnapshot(enabled, reason, motd, changedBy, changedAt, countAffectedOnlinePlayers()); + } + + public int enable(String actor, String nextReason, String nextMotd, boolean kickNonStaff) { + enabled = true; + reason = normalizeReason(nextReason); + motd = normalizeMotd(nextMotd); + changedBy = normalizeActor(actor); + changedAt = Instant.now().toEpochMilli(); + + if (!kickNonStaff) { + return 0; + } + return kickNonStaffPlayers(); + } + + public void disable(String actor) { + enabled = false; + changedBy = normalizeActor(actor); + changedAt = Instant.now().toEpochMilli(); + } + + public boolean isEnabled() { + return enabled; + } + + public String reason() { + return reason; + } + + public String changedBy() { + return changedBy; + } + + public String motd() { + return motd; + } + + public long changedAt() { + return changedAt; + } + + public boolean canBypass(Player player) { + return player != null && (player.isOp() || player.hasPermission(BYPASS_PERMISSION)); + } + + public int kickNonStaffPlayers() { + return runSync(this::kickNonStaffPlayersSync); + } + + private int kickNonStaffPlayersSync() { + int kicked = 0; + for (Player online : context.plugin().getServer().getOnlinePlayers()) { + if (canBypass(online)) { + continue; + } + kicked++; + online.kickPlayer(kickMessage()); + } + return kicked; + } + + public int countAffectedOnlinePlayers() { + return runSync(this::countAffectedOnlinePlayersSync); + } + + private int countAffectedOnlinePlayersSync() { + int affected = 0; + for (Player online : context.plugin().getServer().getOnlinePlayers()) { + if (!canBypass(online)) { + affected++; + } + } + return affected; + } + + public String kickMessage() { + return "Maintenance mode is active. " + reason; + } + + private String normalizeReason(String value) { + if (value == null || value.isBlank()) { + return "Server maintenance in progress"; + } + String trimmed = value.trim(); + return trimmed.length() > 180 ? trimmed.substring(0, 180) : trimmed; + } + + private String normalizeMotd(String value) { + if (value == null || value.isBlank()) { + return "Maintenance mode is active"; + } + String trimmed = value.trim(); + return trimmed.length() > 120 ? trimmed.substring(0, 120) : trimmed; + } + + private String normalizeActor(String value) { + if (value == null || value.isBlank()) { + return "SYSTEM"; + } + return value.trim(); + } + + private int runSync(SyncIntTask task) { + try { + return context.schedulerBridge().callGlobal(task::run, 10, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("maintenance_task_interrupted", exception); + } catch (ExecutionException | TimeoutException exception) { + throw new IllegalStateException("maintenance_task_failed", exception); + } + } + + @FunctionalInterface + private interface SyncIntTask { + int run(); + } + + public record MaintenanceSnapshot( + boolean enabled, + String reason, + String motd, + String changedBy, + long changedAt, + int affectedOnlinePlayers + ) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerManagementExtension.java b/src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerManagementExtension.java new file mode 100644 index 0000000..1bfdcda --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerManagementExtension.java @@ -0,0 +1,308 @@ +package de.winniepat.minePanel.extensions.playermanagement; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import de.winniepat.minePanel.extensions.ExtensionConfigurable; +import de.winniepat.minePanel.extensions.ExtensionContext; +import de.winniepat.minePanel.extensions.ExtensionWebRegistry; +import de.winniepat.minePanel.extensions.MinePanelExtension; +import de.winniepat.minePanel.persistence.ExtensionSettingsRepository; +import de.winniepat.minePanel.persistence.KnownPlayer; +import de.winniepat.minePanel.users.PanelPermission; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; + +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public final class PlayerManagementExtension implements MinePanelExtension, ExtensionConfigurable { + + private final Gson gson = new Gson(); + private ExtensionContext context; + private PlayerMuteRepository muteRepository; + private PlayerMuteListener muteListener; + private ExtensionSettingsRepository extensionSettingsRepository; + + @Override + public String id() { + return "player-management"; + } + + @Override + public String displayName() { + return "Player Management"; + } + + @Override + public void onLoad(ExtensionContext context) { + this.context = context; + this.muteRepository = new PlayerMuteRepository(context.database()); + this.muteRepository.initializeSchema(); + this.extensionSettingsRepository = new ExtensionSettingsRepository(context.database()); + } + + @Override + public void onEnable() { + muteRepository.clearExpired(Instant.now().toEpochMilli()); + ChatFilterSettings settings = loadChatFilterSettings(); + + muteListener = new PlayerMuteListener( + muteRepository, + context.panelLogger(), + settings.enabled(), + settings.badWords(), + settings.autoMuteMinutes(), + settings.autoMuteReason(), + settings.cancelMessage() + ); + context.plugin().getServer().getPluginManager().registerEvents(muteListener, context.plugin()); + } + + @Override + public void onDisable() { + if (muteListener != null) { + HandlerList.unregisterAll(muteListener); + muteListener = null; + } + } + + @Override + public void onSettingsUpdated(String settingsJson) { + ChatFilterSettings latest = parseChatFilterSettings(settingsJson); + if (muteListener == null) { + return; + } + + muteListener.updateFilterConfig( + latest.enabled(), + latest.badWords(), + latest.autoMuteMinutes(), + latest.autoMuteReason(), + latest.cancelMessage() + ); + } + + @Override + public void registerWebRoutes(ExtensionWebRegistry webRegistry) { + webRegistry.post("/api/extensions/player-management/mute", PanelPermission.MANAGE_PLAYER_MANAGEMENT, (request, response, user) -> { + MutePayload payload = gson.fromJson(request.body(), MutePayload.class); + if (payload == null || (isBlank(payload.uuid()) && isBlank(payload.username()))) { + return webRegistry.json(response, 400, Map.of("error", "invalid_payload")); + } + + Optional target = resolveTarget(payload.uuid(), payload.username()); + if (target.isEmpty()) { + return webRegistry.json(response, 404, Map.of("error", "player_not_found")); + } + + int minutes = payload.durationMinutes() == null ? 0 : Math.max(0, Math.min(43_200, payload.durationMinutes())); + String reason = isBlank(payload.reason()) ? "Muted by panel moderator" : payload.reason().trim(); + long now = Instant.now().toEpochMilli(); + Long expiresAt = minutes <= 0 ? null : now + minutes * 60_000L; + + muteRepository.upsertMute(target.get().uuid(), target.get().username(), reason, user.username(), now, expiresAt); + context.panelLogger().log("AUDIT", user.username(), "Muted " + target.get().username() + (minutes > 0 ? " for " + minutes + " minute(s)" : " permanently") + ": " + reason); + + Player online = context.plugin().getServer().getPlayer(target.get().uuid()); + if (online != null) { + online.sendMessage("You were muted by a moderator. Reason: " + reason); + } + + java.util.Map resultPayload = new java.util.HashMap<>(); + resultPayload.put("ok", true); + resultPayload.put("uuid", target.get().uuid().toString()); + resultPayload.put("username", target.get().username()); + resultPayload.put("expiresAt", expiresAt); + return webRegistry.json(response, 200, resultPayload); + }); + + webRegistry.post("/api/extensions/player-management/unmute", PanelPermission.MANAGE_PLAYER_MANAGEMENT, (request, response, user) -> { + UnmutePayload payload = gson.fromJson(request.body(), UnmutePayload.class); + if (payload == null || (isBlank(payload.uuid()) && isBlank(payload.username()))) { + return webRegistry.json(response, 400, Map.of("error", "invalid_payload")); + } + + Optional target = resolveTarget(payload.uuid(), payload.username()); + if (target.isEmpty()) { + return webRegistry.json(response, 404, Map.of("error", "player_not_found")); + } + + boolean removed = muteRepository.removeMute(target.get().uuid()); + if (!removed) { + return webRegistry.json(response, 404, Map.of("error", "player_not_muted")); + } + + context.panelLogger().log("AUDIT", user.username(), "Unmuted " + target.get().username()); + return webRegistry.json(response, 200, Map.of("ok", true)); + }); + + webRegistry.get("/api/extensions/player-management/mute/:uuid", PanelPermission.VIEW_PLAYER_MANAGEMENT, (request, response, user) -> { + UUID uuid; + try { + uuid = UUID.fromString(request.params("uuid")); + } catch (IllegalArgumentException exception) { + return webRegistry.json(response, 400, Map.of("error", "invalid_uuid")); + } + + muteRepository.clearExpired(Instant.now().toEpochMilli()); + Optional mute = muteRepository.findByUuid(uuid); + if (mute.isEmpty()) { + return webRegistry.json(response, 200, Map.of("muted", false)); + } + + return webRegistry.json(response, 200, Map.of( + "muted", true, + "username", mute.get().username(), + "reason", mute.get().reason(), + "mutedBy", mute.get().mutedBy(), + "mutedAt", mute.get().mutedAt(), + "expiresAt", mute.get().expiresAt() + )); + }); + } + + private Optional resolveTarget(String rawUuid, String rawUsername) { + if (!isBlank(rawUuid)) { + try { + UUID uuid = UUID.fromString(rawUuid.trim()); + Optional known = context.knownPlayerRepository().findByUuid(uuid); + if (known.isPresent()) { + return known; + } + } catch (IllegalArgumentException ignored) { + // Fallback to username lookup. + } + } + + if (!isBlank(rawUsername)) { + Optional knownByName = context.knownPlayerRepository().findByUsername(rawUsername.trim()); + if (knownByName.isPresent()) { + return knownByName; + } + + Player online = context.plugin().getServer().getPlayerExact(rawUsername.trim()); + if (online != null) { + long now = Instant.now().toEpochMilli(); + context.knownPlayerRepository().upsert(online.getUniqueId(), online.getName(), now); + return context.knownPlayerRepository().findByUuid(online.getUniqueId()); + } + } + + return Optional.empty(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private Set parseBadWords(List configuredWords) { + Set result = new LinkedHashSet<>(); + if (configuredWords == null) { + return result; + } + + for (String rawWord : configuredWords) { + if (rawWord == null || rawWord.isBlank()) { + continue; + } + result.add(rawWord.trim()); + } + return result; + } + + private ChatFilterSettings loadChatFilterSettings() { + String settingsJson = extensionSettingsRepository.findSettingsJson(id()).orElse("{}"); + return parseChatFilterSettings(settingsJson); + } + + private ChatFilterSettings parseChatFilterSettings(String settingsJson) { + try { + JsonElement root = gson.fromJson(settingsJson, JsonElement.class); + if (root == null || !root.isJsonObject()) { + return ChatFilterSettings.defaults(); + } + + JsonObject rootObject = root.getAsJsonObject(); + JsonObject filter = rootObject.has("badWordFilter") && rootObject.get("badWordFilter").isJsonObject() + ? rootObject.getAsJsonObject("badWordFilter") + : new JsonObject(); + + boolean enabled = booleanValue(filter, "enabled", false); + int autoMuteMinutes = intValue(filter, "autoMuteMinutes", 15, 0, 43_200); + String autoMuteReason = stringValue(filter, "autoMuteReason", "Inappropriate language"); + boolean cancelMessage = booleanValue(filter, "cancelMessage", true); + Set words = parseBadWords(readWords(filter)); + return new ChatFilterSettings(enabled, words, autoMuteMinutes, autoMuteReason, cancelMessage); + } catch (Exception exception) { + context.plugin().getLogger().warning("Could not parse player-management extension settings: " + exception.getMessage()); + return ChatFilterSettings.defaults(); + } + } + + private List readWords(JsonObject filter) { + if (filter == null || !filter.has("words") || !filter.get("words").isJsonArray()) { + return List.of(); + } + + JsonArray array = filter.getAsJsonArray("words"); + java.util.ArrayList words = new java.util.ArrayList<>(); + for (JsonElement element : array) { + if (element == null || !element.isJsonPrimitive()) { + continue; + } + words.add(element.getAsString()); + } + return words; + } + + private boolean booleanValue(JsonObject object, String key, boolean fallback) { + if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()) { + return fallback; + } + try { + return object.get(key).getAsBoolean(); + } catch (Exception ignored) { + return fallback; + } + } + + private int intValue(JsonObject object, String key, int fallback, int min, int max) { + if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()) { + return fallback; + } + try { + int value = object.get(key).getAsInt(); + return Math.max(min, Math.min(max, value)); + } catch (Exception ignored) { + return fallback; + } + } + + private String stringValue(JsonObject object, String key, String fallback) { + if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()) { + return fallback; + } + String value = object.get(key).getAsString(); + return (value == null || value.isBlank()) ? fallback : value.trim(); + } + + private record ChatFilterSettings(boolean enabled, Set badWords, int autoMuteMinutes, String autoMuteReason, boolean cancelMessage) { + private static ChatFilterSettings defaults() { + return new ChatFilterSettings(false, Set.of(), 15, "Inappropriate language", true); + } + } + + private record MutePayload(String uuid, String username, Integer durationMinutes, String reason) { + } + + private record UnmutePayload(String uuid, String username) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerMute.java b/src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerMute.java new file mode 100644 index 0000000..6941097 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerMute.java @@ -0,0 +1,21 @@ +package de.winniepat.minePanel.extensions.playermanagement; + +import java.util.UUID; + +public record PlayerMute( + UUID uuid, + String username, + String reason, + String mutedBy, + long mutedAt, + Long expiresAt +) { + public boolean isExpired(long nowMillis) { + return expiresAt != null && expiresAt > 0 && nowMillis >= expiresAt; + } + + public boolean isActive(long nowMillis) { + return !isExpired(nowMillis); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerMuteListener.java b/src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerMuteListener.java new file mode 100644 index 0000000..7a89716 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerMuteListener.java @@ -0,0 +1,164 @@ +package de.winniepat.minePanel.extensions.playermanagement; + +import de.winniepat.minePanel.logs.PanelLogger; +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.AsyncPlayerChatEvent; + +import java.time.Instant; +import java.util.HashSet; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +public final class PlayerMuteListener implements Listener { + + private static final PlainTextComponentSerializer PLAIN_TEXT_SERIALIZER = PlainTextComponentSerializer.plainText(); + + private final PlayerMuteRepository muteRepository; + private final PanelLogger panelLogger; + private volatile boolean badWordFilterEnabled; + private volatile Set badWords; + private volatile int autoMuteMinutes; + private volatile String autoMuteReason; + private volatile boolean cancelBlockedMessage; + + public PlayerMuteListener( + PlayerMuteRepository muteRepository, + PanelLogger panelLogger, + boolean badWordFilterEnabled, + Set badWords, + int autoMuteMinutes, + String autoMuteReason, + boolean cancelBlockedMessage + ) { + this.muteRepository = muteRepository; + this.panelLogger = panelLogger; + updateFilterConfig(badWordFilterEnabled, badWords, autoMuteMinutes, autoMuteReason, cancelBlockedMessage); + } + + public void updateFilterConfig( + boolean badWordFilterEnabled, + Set badWords, + int autoMuteMinutes, + String autoMuteReason, + boolean cancelBlockedMessage + ) { + this.badWordFilterEnabled = badWordFilterEnabled; + this.badWords = badWords == null ? Set.of() : new HashSet<>(badWords); + this.autoMuteMinutes = Math.max(0, autoMuteMinutes); + this.autoMuteReason = (autoMuteReason == null || autoMuteReason.isBlank()) + ? "Inappropriate language" + : autoMuteReason.trim(); + this.cancelBlockedMessage = cancelBlockedMessage; + } + + @EventHandler(ignoreCancelled = true) + public void onAsyncPlayerChat(AsyncPlayerChatEvent event) { + processChatMessage(event.getPlayer(), event.getMessage(), event::setCancelled); + } + + @EventHandler(ignoreCancelled = true) + public void onAsyncChat(AsyncChatEvent event) { + processChatMessage(event.getPlayer(), PLAIN_TEXT_SERIALIZER.serialize(event.message()), event::setCancelled); + } + + private void processChatMessage(Player player, String message, CancelHandler cancelHandler) { + long now = Instant.now().toEpochMilli(); + Optional mute = muteRepository.findByUuid(player.getUniqueId()); + if (mute.isEmpty()) { + handleBadWordFilter(player, message, now, cancelHandler); + return; + } + + PlayerMute activeMute = mute.get(); + if (activeMute.isExpired(now)) { + muteRepository.removeMute(activeMute.uuid()); + handleBadWordFilter(player, message, now, cancelHandler); + return; + } + + cancelHandler.cancel(true); + if (activeMute.expiresAt() == null) { + player.sendMessage(ChatColor.RED + "You are muted. Reason: " + activeMute.reason()); + return; + } + + long secondsLeft = Math.max(1L, (activeMute.expiresAt() - now) / 1000L); + player.sendMessage(ChatColor.RED + "You are muted for another " + secondsLeft + "s. Reason: " + activeMute.reason()); + } + + private void handleBadWordFilter(Player player, String message, long now, CancelHandler cancelHandler) { + if (!badWordFilterEnabled || badWords.isEmpty()) { + return; + } + + if (player.hasPermission("minepanel.chatfilter.bypass") || player.isOp()) { + return; + } + + String matchedWord = findMatchedBadWord(message); + if (matchedWord == null) { + return; + } + + Long expiresAt = autoMuteMinutes <= 0 ? null : now + autoMuteMinutes * 60_000L; + muteRepository.upsertMute( + player.getUniqueId(), + player.getName(), + autoMuteReason, + "AUTO_MOD", + now, + expiresAt + ); + + if (cancelBlockedMessage) { + cancelHandler.cancel(true); + } + + panelLogger.log( + "SECURITY", + "CHAT_FILTER", + "Auto-muted " + player.getName() + " for bad language (matched: " + matchedWord + ")" + ); + + if (expiresAt == null) { + player.sendMessage(ChatColor.RED + "Your message contained blocked language. You have been muted. Reason: " + autoMuteReason); + return; + } + + player.sendMessage(ChatColor.RED + "Your message contained blocked language. You have been muted for " + autoMuteMinutes + " minute(s). Reason: " + autoMuteReason); + } + + private String findMatchedBadWord(String message) { + if (message == null || message.isBlank()) { + return null; + } + + String lower = message.toLowerCase(Locale.ROOT); + for (String badWord : badWords) { + if (badWord == null || badWord.isBlank()) { + continue; + } + + String normalizedBadWord = badWord.toLowerCase(Locale.ROOT).trim(); + String pattern = "(^|[^a-z0-9])" + Pattern.quote(normalizedBadWord) + "([^a-z0-9]|$)"; + if (Pattern.compile(pattern).matcher(lower).find()) { + return badWord; + } + } + + return null; + } + + @FunctionalInterface + private interface CancelHandler { + void cancel(boolean cancelled); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerMuteRepository.java b/src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerMuteRepository.java new file mode 100644 index 0000000..663c2a1 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerMuteRepository.java @@ -0,0 +1,112 @@ +package de.winniepat.minePanel.extensions.playermanagement; + +import de.winniepat.minePanel.persistence.Database; + +import java.sql.*; +import java.util.*; + +public final class PlayerMuteRepository { + + private final Database database; + + public PlayerMuteRepository(Database database) { + this.database = database; + } + + public void initializeSchema() { + String sql = "CREATE TABLE IF NOT EXISTS player_mutes (" + + "uuid TEXT PRIMARY KEY," + + "username TEXT NOT NULL," + + "reason TEXT NOT NULL," + + "muted_by TEXT NOT NULL," + + "muted_at INTEGER NOT NULL," + + "expires_at INTEGER" + + ")"; + + try (Connection connection = database.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(sql); + } catch (SQLException exception) { + throw new IllegalStateException("Could not initialize mute schema", exception); + } + } + + public void upsertMute(UUID uuid, String username, String reason, String mutedBy, long mutedAt, Long expiresAt) { + String sql = "INSERT INTO player_mutes(uuid, username, reason, muted_by, muted_at, expires_at) VALUES (?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(uuid) DO UPDATE SET " + + "username = excluded.username, " + + "reason = excluded.reason, " + + "muted_by = excluded.muted_by, " + + "muted_at = excluded.muted_at, " + + "expires_at = excluded.expires_at"; + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + statement.setString(2, username == null ? "" : username); + statement.setString(3, reason == null ? "" : reason); + statement.setString(4, mutedBy == null ? "" : mutedBy); + statement.setLong(5, mutedAt); + if (expiresAt == null || expiresAt <= 0) { + statement.setNull(6, Types.BIGINT); + } else { + statement.setLong(6, expiresAt); + } + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not save mute", exception); + } + } + + public Optional findByUuid(UUID uuid) { + String sql = "SELECT uuid, username, reason, muted_by, muted_at, expires_at FROM player_mutes WHERE uuid = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return Optional.of(read(resultSet)); + } + } + return Optional.empty(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not read mute", exception); + } + } + + public boolean removeMute(UUID uuid) { + String sql = "DELETE FROM player_mutes WHERE uuid = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + return statement.executeUpdate() > 0; + } catch (SQLException exception) { + throw new IllegalStateException("Could not delete mute", exception); + } + } + + public int clearExpired(long nowMillis) { + String sql = "DELETE FROM player_mutes WHERE expires_at IS NOT NULL AND expires_at > 0 AND expires_at <= ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, nowMillis); + return statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not clear expired mutes", exception); + } + } + + private PlayerMute read(ResultSet resultSet) throws SQLException { + long rawExpires = resultSet.getLong("expires_at"); + Long expiresAt = resultSet.wasNull() ? null : rawExpires; + return new PlayerMute( + UUID.fromString(resultSet.getString("uuid")), + resultSet.getString("username"), + resultSet.getString("reason"), + resultSet.getString("muted_by"), + resultSet.getLong("muted_at"), + expiresAt + ); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/playerstats/PlayerStatsExtension.java b/src/main/java/de/winniepat/minePanel/extensions/playerstats/PlayerStatsExtension.java new file mode 100644 index 0000000..af9328b --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/playerstats/PlayerStatsExtension.java @@ -0,0 +1,182 @@ +package de.winniepat.minePanel.extensions.playerstats; + +import de.winniepat.minePanel.extensions.ExtensionContext; +import de.winniepat.minePanel.extensions.ExtensionWebRegistry; +import de.winniepat.minePanel.extensions.MinePanelExtension; +import de.winniepat.minePanel.users.PanelPermission; +import org.bukkit.OfflinePlayer; +import org.bukkit.Statistic; + +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public final class PlayerStatsExtension implements MinePanelExtension { + + private ExtensionContext context; + + @Override + public String id() { + return "player-stats"; + } + + @Override + public String displayName() { + return "Player Stats"; + } + + @Override + public void onLoad(ExtensionContext context) { + this.context = context; + } + + @Override + public void registerWebRoutes(ExtensionWebRegistry webRegistry) { + webRegistry.get("/api/extensions/player-stats/player/:uuid", PanelPermission.VIEW_PLAYER_STATS, (request, response, user) -> { + UUID playerUuid; + try { + playerUuid = UUID.fromString(request.params("uuid")); + } catch (IllegalArgumentException exception) { + return webRegistry.json(response, 400, Map.of("error", "invalid_uuid")); + } + + StatsSnapshot snapshot; + try { + snapshot = fetchStats(playerUuid); + } catch (Exception exception) { + return webRegistry.json(response, 500, Map.of( + "available", false, + "error", "player_stats_lookup_failed", + "details", exception.getClass().getSimpleName() + )); + } + + Map payload = new HashMap<>(); + payload.put("available", true); + payload.put("kills", snapshot.kills()); + payload.put("deaths", snapshot.deaths()); + payload.put("economyAvailable", snapshot.economyAvailable()); + payload.put("balance", snapshot.balance()); + payload.put("balanceFormatted", snapshot.balanceFormatted()); + payload.put("economyProvider", snapshot.economyProvider()); + payload.put("generatedAt", System.currentTimeMillis()); + return webRegistry.json(response, 200, payload); + }); + } + + private StatsSnapshot fetchStats(UUID playerUuid) throws ExecutionException, InterruptedException, TimeoutException { + return context.schedulerBridge().callGlobal(() -> { + OfflinePlayer offlinePlayer = context.plugin().getServer().getOfflinePlayer(playerUuid); + int kills = safeStatistic(offlinePlayer, Statistic.PLAYER_KILLS); + int deaths = safeStatistic(offlinePlayer, Statistic.DEATHS); + + EconomyLookup economy = lookupEconomy(offlinePlayer); + return new StatsSnapshot(kills, deaths, economy.available(), economy.balance(), economy.formatted(), economy.provider()); + }, 2, TimeUnit.SECONDS); + } + + private int safeStatistic(OfflinePlayer player, Statistic statistic) { + try { + return player.getStatistic(statistic); + } catch (Exception ignored) { + return 0; + } + } + + private EconomyLookup lookupEconomy(OfflinePlayer player) { + try { + Class economyClass = Class.forName("net.milkbowl.vault.economy.Economy"); + Object registration = context.plugin().getServer().getServicesManager().getRegistration(economyClass); + if (registration == null) { + return EconomyLookup.unavailable(); + } + + Method getProvider = registration.getClass().getMethod("getProvider"); + Object provider = getProvider.invoke(registration); + if (provider == null) { + return EconomyLookup.unavailable(); + } + + String providerName; + try { + Method getName = provider.getClass().getMethod("getName"); + providerName = String.valueOf(getName.invoke(provider)); + } catch (Exception ignored) { + providerName = provider.getClass().getSimpleName(); + } + + Double balance = invokeBalance(provider, player); + if (balance == null) { + return new EconomyLookup(true, null, "-", providerName); + } + + String formatted = formatBalance(provider, balance); + return new EconomyLookup(true, balance, formatted, providerName); + } catch (ClassNotFoundException ignored) { + return EconomyLookup.unavailable(); + } catch (Exception ignored) { + return EconomyLookup.unavailable(); + } + } + + private Double invokeBalance(Object provider, OfflinePlayer player) { + try { + Method getBalanceOffline = provider.getClass().getMethod("getBalance", OfflinePlayer.class); + Object value = getBalanceOffline.invoke(provider, player); + if (value instanceof Number number) { + return number.doubleValue(); + } + } catch (Exception ignored) { + // Fallback below. + } + + try { + Method getBalanceString = provider.getClass().getMethod("getBalance", String.class); + String playerName = player.getName(); + if (playerName == null || playerName.isBlank()) { + return null; + } + Object value = getBalanceString.invoke(provider, playerName); + if (value instanceof Number number) { + return number.doubleValue(); + } + } catch (Exception ignored) { + // No compatible method available. + } + + return null; + } + + private String formatBalance(Object provider, double value) { + try { + Method formatMethod = provider.getClass().getMethod("format", double.class); + Object formatted = formatMethod.invoke(provider, value); + if (formatted != null) { + return String.valueOf(formatted); + } + } catch (Exception ignored) { + // Fallback below. + } + + return String.format(Locale.US, "%.2f", value); + } + + private record StatsSnapshot( + int kills, + int deaths, + boolean economyAvailable, + Double balance, + String balanceFormatted, + String economyProvider + ) { + } + + private record EconomyLookup(boolean available, Double balance, String formatted, String provider) { + private static EconomyLookup unavailable() { + return new EconomyLookup(false, null, "-", ""); + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/reports/PlayerReport.java b/src/main/java/de/winniepat/minePanel/extensions/reports/PlayerReport.java new file mode 100644 index 0000000..b19677f --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/reports/PlayerReport.java @@ -0,0 +1,18 @@ +package de.winniepat.minePanel.extensions.reports; + +import java.util.UUID; + +public record PlayerReport( + long id, + UUID reporterUuid, + String reporterName, + UUID suspectUuid, + String suspectName, + String reason, + String status, + long createdAt, + String reviewedBy, + long reviewedAt +) { +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/reports/ReportCommand.java b/src/main/java/de/winniepat/minePanel/extensions/reports/ReportCommand.java new file mode 100644 index 0000000..668b564 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/reports/ReportCommand.java @@ -0,0 +1,73 @@ +package de.winniepat.minePanel.extensions.reports; + +import de.winniepat.minePanel.logs.PanelLogger; +import de.winniepat.minePanel.persistence.KnownPlayerRepository; +import org.bukkit.ChatColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.time.Instant; +import java.util.Arrays; + +public final class ReportCommand implements CommandExecutor { + + private final ReportRepository reportRepository; + private final KnownPlayerRepository knownPlayerRepository; + private final PanelLogger panelLogger; + + public ReportCommand(ReportRepository reportRepository, KnownPlayerRepository knownPlayerRepository, PanelLogger panelLogger) { + this.reportRepository = reportRepository; + this.knownPlayerRepository = knownPlayerRepository; + this.panelLogger = panelLogger; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player reporter)) { + sender.sendMessage(ChatColor.RED + "Only players can create reports."); + return true; + } + + if (args.length < 2) { + sender.sendMessage(ChatColor.YELLOW + "Usage: /report "); + return true; + } + + Player suspect = reporter.getServer().getPlayerExact(args[0]); + if (suspect == null) { + sender.sendMessage(ChatColor.RED + "The reported player must currently be online."); + return true; + } + + if (suspect.getUniqueId().equals(reporter.getUniqueId())) { + sender.sendMessage(ChatColor.RED + "You cannot report yourself."); + return true; + } + + String reason = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).trim(); + if (reason.isBlank()) { + sender.sendMessage(ChatColor.RED + "Please provide a reason."); + return true; + } + + long now = Instant.now().toEpochMilli(); + knownPlayerRepository.upsert(reporter.getUniqueId(), reporter.getName(), now); + knownPlayerRepository.upsert(suspect.getUniqueId(), suspect.getName(), now); + + long reportId = reportRepository.createReport( + reporter.getUniqueId(), + reporter.getName(), + suspect.getUniqueId(), + suspect.getName(), + reason, + now + ); + + sender.sendMessage(ChatColor.GREEN + "Report submitted (#" + reportId + ") for " + suspect.getName() + "."); + panelLogger.log("REPORT", reporter.getName(), "Created report #" + reportId + " against " + suspect.getName() + ": " + reason); + return true; + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/reports/ReportRepository.java b/src/main/java/de/winniepat/minePanel/extensions/reports/ReportRepository.java new file mode 100644 index 0000000..8dc29e4 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/reports/ReportRepository.java @@ -0,0 +1,130 @@ +package de.winniepat.minePanel.extensions.reports; + +import de.winniepat.minePanel.persistence.Database; + +import java.sql.*; +import java.util.*; + +public final class ReportRepository { + + private final Database database; + + public ReportRepository(Database database) { + this.database = database; + } + + public void initializeSchema() { + String sql = "CREATE TABLE IF NOT EXISTS player_reports (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "reporter_uuid TEXT NOT NULL," + + "reporter_name TEXT NOT NULL," + + "suspect_uuid TEXT NOT NULL," + + "suspect_name TEXT NOT NULL," + + "reason TEXT NOT NULL," + + "status TEXT NOT NULL," + + "created_at INTEGER NOT NULL," + + "reviewed_by TEXT NOT NULL DEFAULT ''," + + "reviewed_at INTEGER NOT NULL DEFAULT 0" + + ")"; + + try (Connection connection = database.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(sql); + } catch (SQLException exception) { + throw new IllegalStateException("Could not initialize report schema", exception); + } + } + + public long createReport(UUID reporterUuid, String reporterName, UUID suspectUuid, String suspectName, String reason, long createdAt) { + String sql = "INSERT INTO player_reports(reporter_uuid, reporter_name, suspect_uuid, suspect_name, reason, status, created_at, reviewed_by, reviewed_at) " + + "VALUES (?, ?, ?, ?, ?, 'OPEN', ?, '', 0)"; + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + statement.setString(1, reporterUuid.toString()); + statement.setString(2, reporterName); + statement.setString(3, suspectUuid.toString()); + statement.setString(4, suspectName); + statement.setString(5, reason == null ? "" : reason); + statement.setLong(6, createdAt); + statement.executeUpdate(); + + try (ResultSet generatedKeys = statement.getGeneratedKeys()) { + if (generatedKeys.next()) { + return generatedKeys.getLong(1); + } + } + return 0L; + } catch (SQLException exception) { + throw new IllegalStateException("Could not create player report", exception); + } + } + + public List listReports(String status) { + boolean filterStatus = status != null && !status.isBlank(); + String sql = filterStatus + ? "SELECT * FROM player_reports WHERE status = ? ORDER BY created_at DESC, id DESC" + : "SELECT * FROM player_reports ORDER BY created_at DESC, id DESC"; + + List reports = new ArrayList<>(); + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + if (filterStatus) { + statement.setString(1, status.trim().toUpperCase(Locale.ROOT)); + } + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + reports.add(read(resultSet)); + } + } + return reports; + } catch (SQLException exception) { + throw new IllegalStateException("Could not list reports", exception); + } + } + + public Optional findById(long id) { + String sql = "SELECT * FROM player_reports WHERE id = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, id); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return Optional.of(read(resultSet)); + } + } + return Optional.empty(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not read report", exception); + } + } + + public boolean markResolved(long id, String reviewedBy, long reviewedAt) { + String sql = "UPDATE player_reports SET status = 'RESOLVED', reviewed_by = ?, reviewed_at = ? WHERE id = ? AND status = 'OPEN'"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, reviewedBy == null ? "" : reviewedBy); + statement.setLong(2, reviewedAt); + statement.setLong(3, id); + return statement.executeUpdate() > 0; + } catch (SQLException exception) { + throw new IllegalStateException("Could not resolve report", exception); + } + } + + private PlayerReport read(ResultSet resultSet) throws SQLException { + return new PlayerReport( + resultSet.getLong("id"), + UUID.fromString(resultSet.getString("reporter_uuid")), + resultSet.getString("reporter_name"), + UUID.fromString(resultSet.getString("suspect_uuid")), + resultSet.getString("suspect_name"), + resultSet.getString("reason"), + resultSet.getString("status"), + resultSet.getLong("created_at"), + resultSet.getString("reviewed_by"), + resultSet.getLong("reviewed_at") + ); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/reports/ReportSystemExtension.java b/src/main/java/de/winniepat/minePanel/extensions/reports/ReportSystemExtension.java new file mode 100644 index 0000000..9f7c7b6 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/reports/ReportSystemExtension.java @@ -0,0 +1,182 @@ +package de.winniepat.minePanel.extensions.reports; + +import com.google.gson.Gson; +import de.winniepat.minePanel.extensions.*; +import de.winniepat.minePanel.logs.PanelLogger; +import de.winniepat.minePanel.persistence.KnownPlayerRepository; +import de.winniepat.minePanel.users.PanelPermission; +import org.bukkit.BanList; +import org.bukkit.entity.Player; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public final class ReportSystemExtension implements MinePanelExtension { + + private final Gson gson = new Gson(); + private ExtensionContext context; + private ReportRepository reportRepository; + + @Override + public String id() { + return "report-system"; + } + + @Override + public String displayName() { + return "Report System"; + } + + @Override + public void onLoad(ExtensionContext context) { + this.context = context; + this.reportRepository = new ReportRepository(context.database()); + this.reportRepository.initializeSchema(); + } + + @Override + public void onEnable() { + KnownPlayerRepository knownPlayerRepository = context.knownPlayerRepository(); + PanelLogger panelLogger = context.panelLogger(); + boolean registered = context.commandRegistry().register( + id(), + "report", + "Report a player to MinePanel moderators", + "/report ", + "minepanel.report", + List.of(), + new ReportCommand(reportRepository, knownPlayerRepository, panelLogger) + ); + + if (!registered) { + context.plugin().getLogger().warning("Could not register /report command for report extension."); + } + } + + @Override + public void registerWebRoutes(ExtensionWebRegistry webRegistry) { + webRegistry.get("/api/extensions/reports", PanelPermission.VIEW_REPORTS, (request, response, user) -> { + String status = request.queryParams("status"); + List> reports = reportRepository.listReports(status).stream().map(this::toReportPayload).toList(); + return webRegistry.json(response, 200, Map.of("reports", reports)); + }); + + webRegistry.post("/api/extensions/reports/:id/resolve", PanelPermission.MANAGE_REPORTS, (request, response, user) -> { + long reportId = parseReportId(request.params("id")); + if (reportId <= 0) { + return webRegistry.json(response, 400, Map.of("error", "invalid_report_id")); + } + + boolean updated = reportRepository.markResolved(reportId, user.username(), Instant.now().toEpochMilli()); + if (!updated) { + return webRegistry.json(response, 404, Map.of("error", "report_not_found_or_closed")); + } + + context.panelLogger().log("AUDIT", user.username(), "Resolved player report #" + reportId); + return webRegistry.json(response, 200, Map.of("ok", true)); + }); + + webRegistry.post("/api/extensions/reports/:id/ban", PanelPermission.MANAGE_REPORTS, (request, response, user) -> { + long reportId = parseReportId(request.params("id")); + if (reportId <= 0) { + return webRegistry.json(response, 400, Map.of("error", "invalid_report_id")); + } + + Optional report = reportRepository.findById(reportId); + if (report.isEmpty()) { + return webRegistry.json(response, 404, Map.of("error", "report_not_found")); + } + + BanPayload payload = gson.fromJson(request.body(), BanPayload.class); + Integer durationMinutes = payload == null ? null : payload.durationMinutes(); + String reason = payload == null || isBlank(payload.reason()) + ? "Banned by report review (#" + reportId + ")" + : payload.reason().trim(); + + BanResult banResult = banPlayer(report.get(), durationMinutes, reason, user.username()); + if (!banResult.success()) { + return webRegistry.json(response, 500, Map.of("error", "ban_failed", "details", banResult.error())); + } + + reportRepository.markResolved(reportId, user.username(), Instant.now().toEpochMilli()); + context.panelLogger().log("AUDIT", user.username(), "Banned " + report.get().suspectName() + " from report #" + reportId + ": " + reason); + java.util.Map resultPayload = new java.util.HashMap<>(); + resultPayload.put("ok", true); + resultPayload.put("username", report.get().suspectName()); + resultPayload.put("expiresAt", banResult.expiresAt()); + return webRegistry.json(response, 200, resultPayload); + }); + } + + @Override + public List navigationTabs() { + return List.of(new ExtensionNavigationTab("server", "Reports", "/dashboard/reports")); + } + + private Map toReportPayload(PlayerReport report) { + return Map.of( + "id", report.id(), + "reporterUuid", report.reporterUuid().toString(), + "reporterName", report.reporterName(), + "suspectUuid", report.suspectUuid().toString(), + "suspectName", report.suspectName(), + "reason", report.reason(), + "status", report.status(), + "createdAt", report.createdAt(), + "reviewedBy", report.reviewedBy(), + "reviewedAt", report.reviewedAt() + ); + } + + private long parseReportId(String rawId) { + try { + return Long.parseLong(rawId); + } catch (Exception ignored) { + return -1L; + } + } + + private BanResult banPlayer(PlayerReport report, Integer durationMinutes, String reason, String actor) { + try { + return context.schedulerBridge().callGlobal(() -> { + Date expiresAt = null; + if (durationMinutes != null && durationMinutes > 0) { + int clampedMinutes = Math.min(durationMinutes, 43_200); + expiresAt = Date.from(Instant.now().plusSeconds(clampedMinutes * 60L)); + } + + context.plugin().getServer().getBanList(BanList.Type.NAME) + .addBan(report.suspectName(), reason, expiresAt, "MinePanelReport:" + actor); + + Player online = context.plugin().getServer().getPlayer(report.suspectUuid()); + if (online == null) { + online = context.plugin().getServer().getPlayerExact(report.suspectName()); + } + if (online != null) { + online.kickPlayer("Banned. Reason: " + reason); + } + + return new BanResult(true, expiresAt == null ? null : expiresAt.getTime(), ""); + }, 2, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return new BanResult(false, null, "interrupted"); + } catch (ExecutionException | TimeoutException exception) { + return new BanResult(false, null, exception.getClass().getSimpleName()); + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private record BanPayload(Integer durationMinutes, String reason) { + } + + private record BanResult(boolean success, Long expiresAt, String error) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/tickets/PlayerTicket.java b/src/main/java/de/winniepat/minePanel/extensions/tickets/PlayerTicket.java new file mode 100644 index 0000000..27de2ff --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/tickets/PlayerTicket.java @@ -0,0 +1,18 @@ +package de.winniepat.minePanel.extensions.tickets; + +import java.util.UUID; + +public record PlayerTicket( + long id, + UUID creatorUuid, + String creatorName, + String category, + String description, + String status, + long createdAt, + long updatedAt, + String handledBy, + long handledAt +) { +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/tickets/TicketCommand.java b/src/main/java/de/winniepat/minePanel/extensions/tickets/TicketCommand.java new file mode 100644 index 0000000..3d651b7 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/tickets/TicketCommand.java @@ -0,0 +1,28 @@ +package de.winniepat.minePanel.extensions.tickets; + +import org.bukkit.ChatColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public final class TicketCommand implements CommandExecutor { + + private final TicketMenuListener menuListener; + + public TicketCommand(TicketMenuListener menuListener) { + this.menuListener = menuListener; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(ChatColor.RED + "Only players can create support tickets."); + return true; + } + + menuListener.openMenu(player); + return true; + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/tickets/TicketMenuListener.java b/src/main/java/de/winniepat/minePanel/extensions/tickets/TicketMenuListener.java new file mode 100644 index 0000000..51d0419 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/tickets/TicketMenuListener.java @@ -0,0 +1,107 @@ +package de.winniepat.minePanel.extensions.tickets; + +import de.winniepat.minePanel.logs.PanelLogger; +import de.winniepat.minePanel.persistence.KnownPlayerRepository; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public final class TicketMenuListener implements Listener { + + private static final String MENU_TITLE = ChatColor.DARK_AQUA + "MinePanel Tickets"; + + private final TicketRepository ticketRepository; + private final KnownPlayerRepository knownPlayerRepository; + private final PanelLogger panelLogger; + + public TicketMenuListener(TicketRepository ticketRepository, KnownPlayerRepository knownPlayerRepository, PanelLogger panelLogger) { + this.ticketRepository = ticketRepository; + this.knownPlayerRepository = knownPlayerRepository; + this.panelLogger = panelLogger; + } + + public void openMenu(Player player) { + Inventory menu = Bukkit.createInventory(null, 27, MENU_TITLE); + + menu.setItem(11, createTicketItem(Material.REDSTONE, ChatColor.RED + "Technical Issue", List.of(ChatColor.GRAY + "Server lag, crashes or bugs"))); + menu.setItem(13, createTicketItem(Material.PAPER, ChatColor.AQUA + "Question / Help", List.of(ChatColor.GRAY + "Need support from the team"))); + menu.setItem(15, createTicketItem(Material.BOOK, ChatColor.GREEN + "Other", List.of(ChatColor.GRAY + "General support request"))); + menu.setItem(22, createTicketItem(Material.BARRIER, ChatColor.RED + "Close", List.of(ChatColor.GRAY + "Close this menu"))); + + player.openInventory(menu); + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + if (!MENU_TITLE.equals(event.getView().getTitle())) { + return; + } + + event.setCancelled(true); + int slot = event.getRawSlot(); + if (slot < 0 || slot >= event.getView().getTopInventory().getSize()) { + return; + } + + Map templates = Map.of( + 11, new TicketTemplate("Technical Issue", "Reported from in-game menu: technical issue"), + 13, new TicketTemplate("Question / Help", "Reported from in-game menu: question or support needed"), + 15, new TicketTemplate("Other", "Reported from in-game menu: general support request") + ); + + if (slot == 22) { + player.closeInventory(); + return; + } + + TicketTemplate template = templates.get(slot); + if (template == null) { + return; + } + + long now = Instant.now().toEpochMilli(); + knownPlayerRepository.upsert(player.getUniqueId(), player.getName(), now); + + long ticketId = ticketRepository.createTicket( + player.getUniqueId(), + player.getName(), + template.category(), + template.description(), + now + ); + + player.closeInventory(); + player.sendMessage(ChatColor.GREEN + "Ticket submitted (#" + ticketId + ") in category " + template.category() + "."); + panelLogger.log("TICKET", player.getName(), "Created ticket #" + ticketId + " [" + template.category() + "] " + template.description()); + } + + private ItemStack createTicketItem(Material material, String name, List lore) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(name); + meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + + private record TicketTemplate(String category, String description) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/tickets/TicketRepository.java b/src/main/java/de/winniepat/minePanel/extensions/tickets/TicketRepository.java new file mode 100644 index 0000000..9d1261d --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/tickets/TicketRepository.java @@ -0,0 +1,120 @@ +package de.winniepat.minePanel.extensions.tickets; + +import de.winniepat.minePanel.persistence.Database; + +import java.sql.*; +import java.util.*; + +public final class TicketRepository { + + private final Database database; + + public TicketRepository(Database database) { + this.database = database; + } + + public void initializeSchema() { + String sql = "CREATE TABLE IF NOT EXISTS player_tickets (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "creator_uuid TEXT NOT NULL," + + "creator_name TEXT NOT NULL," + + "category TEXT NOT NULL," + + "description TEXT NOT NULL," + + "status TEXT NOT NULL," + + "created_at INTEGER NOT NULL," + + "updated_at INTEGER NOT NULL," + + "handled_by TEXT NOT NULL DEFAULT ''," + + "handled_at INTEGER NOT NULL DEFAULT 0" + + ")"; + + try (Connection connection = database.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(sql); + } catch (SQLException exception) { + throw new IllegalStateException("Could not initialize ticket schema", exception); + } + } + + public long createTicket(UUID creatorUuid, String creatorName, String category, String description, long createdAt) { + String sql = "INSERT INTO player_tickets(creator_uuid, creator_name, category, description, status, created_at, updated_at, handled_by, handled_at) " + + "VALUES (?, ?, ?, ?, 'OPEN', ?, ?, '', 0)"; + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + statement.setString(1, creatorUuid.toString()); + statement.setString(2, creatorName == null ? "Unknown" : creatorName); + statement.setString(3, category == null || category.isBlank() ? "Other" : category.trim()); + statement.setString(4, description == null ? "" : description.trim()); + statement.setLong(5, createdAt); + statement.setLong(6, createdAt); + statement.executeUpdate(); + + try (ResultSet generatedKeys = statement.getGeneratedKeys()) { + if (generatedKeys.next()) { + return generatedKeys.getLong(1); + } + } + return 0L; + } catch (SQLException exception) { + throw new IllegalStateException("Could not create ticket", exception); + } + } + + public List listTickets(String status) { + boolean filterStatus = status != null && !status.isBlank(); + String sql = filterStatus + ? "SELECT * FROM player_tickets WHERE status = ? ORDER BY created_at DESC, id DESC" + : "SELECT * FROM player_tickets ORDER BY created_at DESC, id DESC"; + + List tickets = new ArrayList<>(); + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + if (filterStatus) { + statement.setString(1, status.trim().toUpperCase(Locale.ROOT)); + } + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + tickets.add(read(resultSet)); + } + } + return tickets; + } catch (SQLException exception) { + throw new IllegalStateException("Could not list tickets", exception); + } + } + + public boolean updateStatus(long id, String status, String handledBy, long handledAt) { + if (id <= 0 || status == null || status.isBlank()) { + return false; + } + + String sql = "UPDATE player_tickets SET status = ?, updated_at = ?, handled_by = ?, handled_at = ? WHERE id = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, status.trim().toUpperCase(Locale.ROOT)); + statement.setLong(2, handledAt); + statement.setString(3, handledBy == null ? "" : handledBy); + statement.setLong(4, handledAt); + statement.setLong(5, id); + return statement.executeUpdate() > 0; + } catch (SQLException exception) { + throw new IllegalStateException("Could not update ticket status", exception); + } + } + + private PlayerTicket read(ResultSet resultSet) throws SQLException { + return new PlayerTicket( + resultSet.getLong("id"), + UUID.fromString(resultSet.getString("creator_uuid")), + resultSet.getString("creator_name"), + resultSet.getString("category"), + resultSet.getString("description"), + resultSet.getString("status"), + resultSet.getLong("created_at"), + resultSet.getLong("updated_at"), + resultSet.getString("handled_by"), + resultSet.getLong("handled_at") + ); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/tickets/TicketSystemExtension.java b/src/main/java/de/winniepat/minePanel/extensions/tickets/TicketSystemExtension.java new file mode 100644 index 0000000..e4a887e --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/tickets/TicketSystemExtension.java @@ -0,0 +1,131 @@ +package de.winniepat.minePanel.extensions.tickets; + +import de.winniepat.minePanel.extensions.ExtensionContext; +import de.winniepat.minePanel.extensions.ExtensionNavigationTab; +import de.winniepat.minePanel.extensions.ExtensionWebRegistry; +import de.winniepat.minePanel.extensions.MinePanelExtension; +import de.winniepat.minePanel.users.PanelPermission; +import org.bukkit.event.HandlerList; + +import java.time.Instant; +import java.util.*; + +public final class TicketSystemExtension implements MinePanelExtension { + + private ExtensionContext context; + private TicketRepository ticketRepository; + private TicketMenuListener menuListener; + + @Override + public String id() { + return "ticket-system"; + } + + @Override + public String displayName() { + return "Ticket System"; + } + + @Override + public void onLoad(ExtensionContext context) { + this.context = context; + this.ticketRepository = new TicketRepository(context.database()); + this.ticketRepository.initializeSchema(); + } + + @Override + public void onEnable() { + this.menuListener = new TicketMenuListener(ticketRepository, context.knownPlayerRepository(), context.panelLogger()); + context.plugin().getServer().getPluginManager().registerEvents(menuListener, context.plugin()); + + boolean registered = context.commandRegistry().register( + id(), + "ticket", + "Create a support ticket for server staff", + "/ticket", + "minepanel.ticket", + List.of("support"), + new TicketCommand(menuListener) + ); + + if (!registered) { + context.plugin().getLogger().warning("Could not register /ticket command for ticket extension."); + } + } + + @Override + public void onDisable() { + if (menuListener != null) { + HandlerList.unregisterAll(menuListener); + menuListener = null; + } + } + + @Override + public void registerWebRoutes(ExtensionWebRegistry webRegistry) { + webRegistry.get("/api/extensions/tickets", PanelPermission.VIEW_TICKETS, (request, response, user) -> { + String status = request.queryParams("status"); + List> tickets = ticketRepository.listTickets(status).stream().map(this::toPayload).toList(); + return webRegistry.json(response, 200, Map.of("tickets", tickets)); + }); + + webRegistry.post("/api/extensions/tickets/:id/close", PanelPermission.MANAGE_TICKETS, (request, response, user) -> { + long ticketId = parseTicketId(request.params("id")); + if (ticketId <= 0) { + return webRegistry.json(response, 400, Map.of("error", "invalid_ticket_id")); + } + + boolean updated = ticketRepository.updateStatus(ticketId, "CLOSED", user.username(), Instant.now().toEpochMilli()); + if (!updated) { + return webRegistry.json(response, 404, Map.of("error", "ticket_not_found")); + } + + context.panelLogger().log("AUDIT", user.username(), "Closed ticket #" + ticketId); + return webRegistry.json(response, 200, Map.of("ok", true)); + }); + + webRegistry.post("/api/extensions/tickets/:id/reopen", PanelPermission.MANAGE_TICKETS, (request, response, user) -> { + long ticketId = parseTicketId(request.params("id")); + if (ticketId <= 0) { + return webRegistry.json(response, 400, Map.of("error", "invalid_ticket_id")); + } + + boolean updated = ticketRepository.updateStatus(ticketId, "OPEN", user.username(), Instant.now().toEpochMilli()); + if (!updated) { + return webRegistry.json(response, 404, Map.of("error", "ticket_not_found")); + } + + context.panelLogger().log("AUDIT", user.username(), "Reopened ticket #" + ticketId); + return webRegistry.json(response, 200, Map.of("ok", true)); + }); + } + + @Override + public List navigationTabs() { + return List.of(new ExtensionNavigationTab("server", "Tickets", "/dashboard/tickets")); + } + + private long parseTicketId(String rawId) { + try { + return Long.parseLong(rawId); + } catch (Exception ignored) { + return -1L; + } + } + + private Map toPayload(PlayerTicket ticket) { + return Map.of( + "id", ticket.id(), + "creatorUuid", ticket.creatorUuid().toString(), + "creatorName", ticket.creatorName(), + "category", ticket.category(), + "description", ticket.description(), + "status", ticket.status(), + "createdAt", ticket.createdAt(), + "updatedAt", ticket.updatedAt(), + "handledBy", ticket.handledBy(), + "handledAt", ticket.handledAt() + ); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/whitelist/WhitelistExtension.java b/src/main/java/de/winniepat/minePanel/extensions/whitelist/WhitelistExtension.java new file mode 100644 index 0000000..22ff2f7 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/whitelist/WhitelistExtension.java @@ -0,0 +1,139 @@ +package de.winniepat.minePanel.extensions.whitelist; + +import com.google.gson.Gson; +import de.winniepat.minePanel.extensions.ExtensionContext; +import de.winniepat.minePanel.extensions.ExtensionNavigationTab; +import de.winniepat.minePanel.extensions.ExtensionWebRegistry; +import de.winniepat.minePanel.extensions.MinePanelExtension; +import de.winniepat.minePanel.users.PanelPermission; + +import java.util.List; +import java.util.Map; + +public final class WhitelistExtension implements MinePanelExtension { + + private final Gson gson = new Gson(); + + private ExtensionContext context; + private WhitelistService whitelistService; + + @Override + public String id() { + return "whitelist"; + } + + @Override + public String displayName() { + return "Whitelist"; + } + + @Override + public void onLoad(ExtensionContext context) { + this.context = context; + this.whitelistService = new WhitelistService(context); + } + + @Override + public void registerWebRoutes(ExtensionWebRegistry webRegistry) { + webRegistry.get("/api/extensions/whitelist/status", PanelPermission.VIEW_WHITELIST, (request, response, user) -> { + try { + boolean enabled = whitelistService.isWhitelistEnabled(); + return webRegistry.json(response, 200, Map.of("enabled", enabled)); + } catch (IllegalStateException exception) { + return webRegistry.json(response, 500, Map.of("error", "whitelist_status_failed", "details", exception.getMessage())); + } + }); + + webRegistry.post("/api/extensions/whitelist/toggle", PanelPermission.MANAGE_WHITELIST, (request, response, user) -> { + TogglePayload payload = gson.fromJson(request.body(), TogglePayload.class); + if (payload == null || payload.enabled() == null) { + return webRegistry.json(response, 400, Map.of("error", "invalid_payload")); + } + + boolean changed; + try { + changed = whitelistService.setWhitelistEnabled(payload.enabled()); + } catch (IllegalStateException exception) { + return webRegistry.json(response, 500, Map.of("error", "whitelist_toggle_failed", "details", exception.getMessage())); + } + + context.panelLogger().log( + "AUDIT", + user.username(), + (payload.enabled() ? "Enabled" : "Disabled") + " server whitelist" + (changed ? "" : " (no change)") + ); + return webRegistry.json(response, 200, Map.of("ok", true, "enabled", payload.enabled(), "changed", changed)); + }); + + webRegistry.get("/api/extensions/whitelist", PanelPermission.VIEW_WHITELIST, (request, response, user) -> { + try { + List> entries = whitelistService.listEntries(); + return webRegistry.json(response, 200, Map.of("entries", entries, "count", entries.size())); + } catch (IllegalStateException exception) { + return webRegistry.json(response, 500, Map.of("error", "whitelist_list_failed", "details", exception.getMessage())); + } + }); + + webRegistry.post("/api/extensions/whitelist/add", PanelPermission.MANAGE_WHITELIST, (request, response, user) -> { + UsernamePayload payload = gson.fromJson(request.body(), UsernamePayload.class); + String username = payload == null ? null : payload.username(); + + WhitelistService.ChangeResult result; + try { + result = whitelistService.addByUsername(username); + } catch (IllegalStateException exception) { + return webRegistry.json(response, 500, Map.of("error", "whitelist_add_failed", "details", exception.getMessage())); + } + + if (!result.success()) { + return webRegistry.json(response, 400, Map.of("error", result.error())); + } + + context.panelLogger().log("AUDIT", user.username(), + (result.changed() ? "Added " : "Kept ") + result.username() + " on whitelist"); + return webRegistry.json(response, 200, Map.of( + "ok", true, + "changed", result.changed(), + "uuid", result.uuid() == null ? "" : result.uuid().toString(), + "username", result.username() + )); + }); + + webRegistry.post("/api/extensions/whitelist/remove", PanelPermission.MANAGE_WHITELIST, (request, response, user) -> { + UsernamePayload payload = gson.fromJson(request.body(), UsernamePayload.class); + String username = payload == null ? null : payload.username(); + + WhitelistService.ChangeResult result; + try { + result = whitelistService.removeByUsername(username); + } catch (IllegalStateException exception) { + return webRegistry.json(response, 500, Map.of("error", "whitelist_remove_failed", "details", exception.getMessage())); + } + + if (!result.success()) { + int status = "not_whitelisted".equals(result.error()) ? 404 : 400; + return webRegistry.json(response, status, Map.of("error", result.error())); + } + + context.panelLogger().log("AUDIT", user.username(), "Removed " + result.username() + " from whitelist"); + return webRegistry.json(response, 200, Map.of( + "ok", true, + "changed", result.changed(), + "uuid", result.uuid() == null ? "" : result.uuid().toString(), + "username", result.username() + )); + }); + } + + @Override + public List navigationTabs() { + return List.of(new ExtensionNavigationTab("server", "Whitelist", "/dashboard/whitelist")); + } + + private record UsernamePayload(String username) { + } + + private record TogglePayload(Boolean enabled) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/whitelist/WhitelistService.java b/src/main/java/de/winniepat/minePanel/extensions/whitelist/WhitelistService.java new file mode 100644 index 0000000..47659e7 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/whitelist/WhitelistService.java @@ -0,0 +1,155 @@ +package de.winniepat.minePanel.extensions.whitelist; + +import de.winniepat.minePanel.extensions.ExtensionContext; +import org.bukkit.OfflinePlayer; + +import java.util.*; +import java.util.concurrent.*; + +public final class WhitelistService { + + private final ExtensionContext context; + + public WhitelistService(ExtensionContext context) { + this.context = context; + } + + public List> listEntries() { + return runSync(() -> { + List> entries = new ArrayList<>(); + for (OfflinePlayer player : context.plugin().getServer().getWhitelistedPlayers()) { + String name = player.getName(); + String username = (name == null || name.isBlank()) ? player.getUniqueId().toString() : name; + entries.add(Map.of( + "uuid", player.getUniqueId().toString(), + "username", username, + "online", player.isOnline() + )); + } + + entries.sort(Comparator.comparing(entry -> String.valueOf(entry.get("username")), String.CASE_INSENSITIVE_ORDER)); + return entries; + }); + } + + public boolean isWhitelistEnabled() { + return runSync(() -> context.plugin().getServer().hasWhitelist()); + } + + public boolean setWhitelistEnabled(boolean enabled) { + return runSync(() -> { + boolean before = context.plugin().getServer().hasWhitelist(); + if (before == enabled) { + return false; + } + context.plugin().getServer().setWhitelist(enabled); + return true; + }); + } + + public ChangeResult addByUsername(String rawUsername) { + String username = normalizeUsername(rawUsername); + if (username.isBlank()) { + return ChangeResult.invalid("invalid_username"); + } + + return runSync(() -> { + OfflinePlayer target = resolvePlayerByUsername(username); + if (target == null) { + return ChangeResult.invalid("player_not_found"); + } + + if (target.isWhitelisted()) { + return ChangeResult.ok(target, false); + } + + target.setWhitelisted(true); + return ChangeResult.ok(target, true); + }); + } + + public ChangeResult removeByUsername(String rawUsername) { + String username = normalizeUsername(rawUsername); + if (username.isBlank()) { + return ChangeResult.invalid("invalid_username"); + } + + return runSync(() -> { + OfflinePlayer target = findWhitelistedByUsername(username); + if (target == null) { + return ChangeResult.invalid("not_whitelisted"); + } + + target.setWhitelisted(false); + if (target.isOnline() && target.getPlayer() != null) { + target.getPlayer().kickPlayer("You have been removed from the whitelist."); + } + return ChangeResult.ok(target, true); + }); + } + + private OfflinePlayer resolvePlayerByUsername(String username) { + OfflinePlayer online = context.plugin().getServer().getPlayerExact(username); + if (online != null) { + return online; + } + + for (OfflinePlayer offline : context.plugin().getServer().getOfflinePlayers()) { + if (offline.getName() != null && offline.getName().equalsIgnoreCase(username)) { + return offline; + } + } + + OfflinePlayer fallback = context.plugin().getServer().getOfflinePlayer(username); + return (fallback.getName() == null && !fallback.hasPlayedBefore()) ? null : fallback; + } + + private OfflinePlayer findWhitelistedByUsername(String username) { + for (OfflinePlayer player : context.plugin().getServer().getWhitelistedPlayers()) { + String name = player.getName(); + if (name != null && name.equalsIgnoreCase(username)) { + return player; + } + } + return null; + } + + private String normalizeUsername(String value) { + if (value == null) { + return ""; + } + String username = value.trim(); + if (username.length() < 3 || username.length() > 16) { + return ""; + } + if (!username.matches("^[A-Za-z0-9_]+$")) { + return ""; + } + return username; + } + + private T runSync(Callable task) { + try { + return context.schedulerBridge().callGlobal(task, 10, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("whitelist_task_interrupted", exception); + } catch (ExecutionException | TimeoutException exception) { + throw new IllegalStateException("whitelist_task_failed", exception); + } + } + + public record ChangeResult(boolean success, boolean changed, String error, UUID uuid, String username) { + private static ChangeResult ok(OfflinePlayer player, boolean changed) { + String username = player.getName() == null || player.getName().isBlank() + ? player.getUniqueId().toString() + : player.getName(); + return new ChangeResult(true, changed, "", player.getUniqueId(), username); + } + + private static ChangeResult invalid(String error) { + return new ChangeResult(false, false, error, null, ""); + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/worldbackups/WorldBackupService.java b/src/main/java/de/winniepat/minePanel/extensions/worldbackups/WorldBackupService.java new file mode 100644 index 0000000..76fdc7c --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/worldbackups/WorldBackupService.java @@ -0,0 +1,389 @@ +package de.winniepat.minePanel.extensions.worldbackups; + +import de.winniepat.minePanel.MinePanel; +import org.bukkit.World; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +public final class WorldBackupService { + + private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss").withZone(ZoneOffset.UTC); + private static final Set WORLD_KEYS = Set.of("overworld", "nether", "end"); + + private final MinePanel plugin; + private final Path backupsRoot; + + public WorldBackupService(MinePanel plugin, Path backupsRoot) { + this.plugin = plugin; + this.backupsRoot = backupsRoot; + } + + public List supportedWorldKeys() { + return List.of("overworld", "nether", "end"); + } + + public BackupCreateResult createBackup(String worldKey, String requestedName) { + String normalizedWorldKey = normalizeWorldKey(worldKey); + if (normalizedWorldKey == null) { + return BackupCreateResult.error("invalid_world"); + } + + String safeName = sanitizeBackupName(requestedName); + if (safeName.isBlank()) { + return BackupCreateResult.error("invalid_name"); + } + + WorldSnapshot snapshot = captureWorldSnapshot(normalizedWorldKey); + if (snapshot == null) { + return BackupCreateResult.error("world_not_found"); + } + + Path worldBackupDir = backupsRoot.resolve(normalizedWorldKey); + String fileName = FILE_TIMESTAMP.format(Instant.now()) + "__" + safeName + ".zip"; + Path tempZip = null; + try { + Files.createDirectories(backupsRoot); + Files.createDirectories(worldBackupDir); + Path zipFile = resolveUniqueZipPath(worldBackupDir, fileName); + tempZip = resolveUniqueTempPath(worldBackupDir); + zipDirectory(snapshot.worldFolder(), tempZip); + + try { + Files.move(tempZip, zipFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException ignored) { + Files.move(tempZip, zipFile, StandardCopyOption.REPLACE_EXISTING); + } + + long sizeBytes = Files.size(zipFile); + long createdAt = Files.getLastModifiedTime(zipFile).toMillis(); + return BackupCreateResult.success(new WorldBackupEntry(zipFile.getFileName().toString(), safeName, createdAt, sizeBytes, normalizedWorldKey)); + } catch (Exception exception) { + return BackupCreateResult.error("backup_failed", buildErrorDetails(exception)); + } finally { + if (tempZip != null) { + try { + Files.deleteIfExists(tempZip); + } catch (IOException ignored) { + // Best effort cleanup. + } + } + } + } + + private Path resolveUniqueZipPath(Path worldBackupDir, String baseFileName) throws IOException { + Path initial = worldBackupDir.resolve(baseFileName).normalize(); + if (!Files.exists(initial)) { + return initial; + } + + String base = baseFileName; + String suffix = ".zip"; + if (baseFileName.toLowerCase(Locale.ROOT).endsWith(suffix)) { + base = baseFileName.substring(0, baseFileName.length() - suffix.length()); + } + + for (int i = 2; i <= 9999; i++) { + Path candidate = worldBackupDir.resolve(base + "-" + i + suffix).normalize(); + if (!Files.exists(candidate)) { + return candidate; + } + } + + throw new IOException("could_not_allocate_unique_backup_file_name"); + } + + private Path resolveUniqueTempPath(Path worldBackupDir) throws IOException { + for (int i = 0; i < 10_000; i++) { + String candidateName = "backup-" + UUID.randomUUID() + ".tmp"; + Path candidate = worldBackupDir.resolve(candidateName).normalize(); + if (!Files.exists(candidate)) { + return candidate; + } + } + + throw new IOException("could_not_allocate_unique_temp_file_name"); + } + + private String buildErrorDetails(Exception exception) { + String message = exception.getMessage(); + if (message == null || message.isBlank()) { + return exception.getClass().getSimpleName(); + } + return exception.getClass().getSimpleName() + ": " + message; + } + + public List listBackups(String worldKey) { + String normalizedWorldKey = normalizeWorldKey(worldKey); + if (normalizedWorldKey == null) { + return List.of(); + } + + Path worldBackupDir = backupsRoot.resolve(normalizedWorldKey); + if (!Files.isDirectory(worldBackupDir)) { + return List.of(); + } + + try (var files = Files.list(worldBackupDir)) { + return files + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip")) + .map(path -> toEntry(path, normalizedWorldKey)) + .filter(Objects::nonNull) + .sorted(Comparator.comparingLong(WorldBackupEntry::createdAt).reversed()) + .toList(); + } catch (IOException exception) { + return List.of(); + } + } + + public BackupDeleteResult deleteBackup(String worldKey, String fileName) { + String normalizedWorldKey = normalizeWorldKey(worldKey); + if (normalizedWorldKey == null) { + return BackupDeleteResult.error("invalid_world"); + } + + if (fileName == null || fileName.isBlank()) { + return BackupDeleteResult.error("invalid_file_name"); + } + + String trimmedFileName = fileName.trim(); + if (!trimmedFileName.toLowerCase(Locale.ROOT).endsWith(".zip")) { + return BackupDeleteResult.error("invalid_file_name"); + } + if (trimmedFileName.contains("/") || trimmedFileName.contains("\\") || trimmedFileName.contains("..")) { + return BackupDeleteResult.error("invalid_file_name"); + } + + Path worldBackupDir = backupsRoot.resolve(normalizedWorldKey).normalize(); + Path target = worldBackupDir.resolve(trimmedFileName).normalize(); + if (!target.startsWith(worldBackupDir)) { + return BackupDeleteResult.error("invalid_file_name"); + } + + if (!Files.exists(target) || !Files.isRegularFile(target)) { + return BackupDeleteResult.error("backup_not_found"); + } + + try { + Files.delete(target); + return BackupDeleteResult.success(trimmedFileName, normalizedWorldKey); + } catch (Exception exception) { + return BackupDeleteResult.error("delete_failed", buildErrorDetails(exception)); + } + } + + private WorldSnapshot captureWorldSnapshot(String worldKey) { + try { + return plugin.schedulerBridge().callGlobal(() -> { + World world = resolveWorld(worldKey); + if (world == null) { + return null; + } + world.save(); + return new WorldSnapshot(world.getName(), world.getWorldFolder().toPath()); + }, 3, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return null; + } catch (ExecutionException | TimeoutException exception) { + return null; + } + } + + private World resolveWorld(String worldKey) { + String baseLevelName = plugin.getServer().getWorlds().isEmpty() + ? "world" + : plugin.getServer().getWorlds().get(0).getName(); + + return switch (worldKey) { + case "overworld" -> firstWorldByNames(List.of(baseLevelName, "world"), World.Environment.NORMAL); + case "nether" -> firstWorldByNames(List.of(baseLevelName + "_nether", "world_nether"), World.Environment.NETHER); + case "end" -> firstWorldByNames(List.of(baseLevelName + "_the_end", "world_the_end", baseLevelName + "_end"), World.Environment.THE_END); + default -> null; + }; + } + + private World firstWorldByNames(List candidates, World.Environment fallbackEnvironment) { + for (String candidate : candidates) { + if (candidate == null || candidate.isBlank()) { + continue; + } + World world = plugin.getServer().getWorld(candidate); + if (world != null) { + return world; + } + } + + return plugin.getServer().getWorlds().stream() + .filter(world -> world.getEnvironment() == fallbackEnvironment) + .findFirst() + .orElse(null); + } + + private WorldBackupEntry toEntry(Path file, String worldKey) { + try { + String fileName = file.getFileName().toString(); + String displayName = parseNameFromFileName(fileName); + long createdAt = Files.getLastModifiedTime(file).toMillis(); + long sizeBytes = Files.size(file); + return new WorldBackupEntry(fileName, displayName, createdAt, sizeBytes, worldKey); + } catch (IOException exception) { + return null; + } + } + + private String parseNameFromFileName(String fileName) { + int separatorIndex = fileName.indexOf("__"); + if (separatorIndex < 0) { + return fileName; + } + + String withoutPrefix = fileName.substring(separatorIndex + 2); + if (withoutPrefix.toLowerCase(Locale.ROOT).endsWith(".zip")) { + return withoutPrefix.substring(0, withoutPrefix.length() - 4); + } + return withoutPrefix; + } + + private void zipDirectory(Path sourceDirectory, Path zipFile) throws IOException { + try (ZipOutputStream zipStream = new ZipOutputStream(Files.newOutputStream(zipFile, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE))) { + Files.walkFileTree(sourceDirectory, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attrs) throws IOException { + Path relative = sourceDirectory.relativize(directory); + if (!relative.toString().isEmpty()) { + String entryName = normalizeEntryName(relative) + "/"; + try { + zipStream.putNextEntry(new ZipEntry(entryName)); + zipStream.closeEntry(); + } catch (IOException ignored) { + // Ignore duplicate/invalid directory entries and continue. + } + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + if (Files.isSymbolicLink(file)) { + return FileVisitResult.CONTINUE; + } + + Path relative = sourceDirectory.relativize(file); + if (shouldSkipFile(relative)) { + return FileVisitResult.CONTINUE; + } + + if (!Files.isReadable(file)) { + return FileVisitResult.CONTINUE; + } + + String entryName = normalizeEntryName(relative); + try { + zipStream.putNextEntry(new ZipEntry(entryName)); + Files.copy(file, zipStream); + zipStream.closeEntry(); + } catch (IOException ignored) { + // Skip files that are temporarily locked by the OS or server process. + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + // Continue if a file cannot be read due to locks/permissions. + return FileVisitResult.CONTINUE; + } + }); + } catch (UncheckedIOException exception) { + throw exception.getCause(); + } + } + + private boolean shouldSkipFile(Path relativePath) { + String name = relativePath.getFileName() == null ? "" : relativePath.getFileName().toString().toLowerCase(Locale.ROOT); + return "session.lock".equals(name); + } + + private String normalizeEntryName(Path relativePath) { + return relativePath.toString().replace('\\', '/'); + } + + private String sanitizeBackupName(String rawName) { + if (rawName == null) { + return ""; + } + + StringBuilder sanitized = new StringBuilder(); + String trimmed = rawName.trim(); + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') { + sanitized.append(c); + } else if (c == ' ' || c == '.') { + sanitized.append('_'); + } + } + + String value = sanitized.toString().replaceAll("_+", "_"); + if (value.length() > 48) { + return value.substring(0, 48); + } + return value; + } + + private String normalizeWorldKey(String worldKey) { + if (worldKey == null) { + return null; + } + String normalized = worldKey.trim().toLowerCase(Locale.ROOT); + return WORLD_KEYS.contains(normalized) ? normalized : null; + } + + public record WorldBackupEntry(String fileName, String name, long createdAt, long sizeBytes, String world) { + } + + public record BackupCreateResult(boolean success, String error, String details, WorldBackupEntry backup) { + static BackupCreateResult success(WorldBackupEntry backup) { + return new BackupCreateResult(true, "", "", backup); + } + + static BackupCreateResult error(String error) { + return new BackupCreateResult(false, error, "", null); + } + + static BackupCreateResult error(String error, String details) { + return new BackupCreateResult(false, error, details == null ? "" : details, null); + } + } + + public record BackupDeleteResult(boolean success, String error, String details, String fileName, String world) { + static BackupDeleteResult success(String fileName, String world) { + return new BackupDeleteResult(true, "", "", fileName, world); + } + + static BackupDeleteResult error(String error) { + return new BackupDeleteResult(false, error, "", "", ""); + } + + static BackupDeleteResult error(String error, String details) { + return new BackupDeleteResult(false, error, details == null ? "" : details, "", ""); + } + } + + private record WorldSnapshot(String worldName, Path worldFolder) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/worldbackups/WorldBackupsExtension.java b/src/main/java/de/winniepat/minePanel/extensions/worldbackups/WorldBackupsExtension.java new file mode 100644 index 0000000..83455d8 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/worldbackups/WorldBackupsExtension.java @@ -0,0 +1,145 @@ +package de.winniepat.minePanel.extensions.worldbackups; + +import com.google.gson.Gson; +import de.winniepat.minePanel.extensions.ExtensionContext; +import de.winniepat.minePanel.extensions.ExtensionNavigationTab; +import de.winniepat.minePanel.extensions.ExtensionWebRegistry; +import de.winniepat.minePanel.extensions.MinePanelExtension; +import de.winniepat.minePanel.users.PanelPermission; + +import java.nio.file.Path; +import java.util.*; + +public final class WorldBackupsExtension implements MinePanelExtension { + + private final Gson gson = new Gson(); + + private ExtensionContext context; + private WorldBackupService backupService; + + @Override + public String id() { + return "world-backups"; + } + + @Override + public String displayName() { + return "World Backups"; + } + + @Override + public void onLoad(ExtensionContext context) { + this.context = context; + Path backupRoot = context.plugin().getDataFolder().toPath().resolve("backups"); + this.backupService = new WorldBackupService(context.plugin(), backupRoot); + } + + @Override + public void registerWebRoutes(ExtensionWebRegistry webRegistry) { + webRegistry.get("/api/extensions/world-backups", PanelPermission.VIEW_BACKUPS, (request, response, user) -> { + String world = request.queryParams("world"); + if (world != null && !world.isBlank()) { + List> backups = backupService.listBackups(world).stream().map(this::toPayload).toList(); + return webRegistry.json(response, 200, Map.of("world", world, "backups", backups)); + } + + Map payload = new HashMap<>(); + for (String worldKey : backupService.supportedWorldKeys()) { + List> backups = backupService.listBackups(worldKey).stream().map(this::toPayload).toList(); + payload.put(worldKey, backups); + } + + payload.put("worlds", backupService.supportedWorldKeys()); + return webRegistry.json(response, 200, payload); + }); + + webRegistry.post("/api/extensions/world-backups/create", PanelPermission.MANAGE_BACKUPS, (request, response, user) -> { + CreateBackupPayload payload = gson.fromJson(request.body(), CreateBackupPayload.class); + if (payload == null || isBlank(payload.world()) || isBlank(payload.name())) { + return webRegistry.json(response, 400, Map.of("error", "invalid_payload")); + } + + WorldBackupService.BackupCreateResult result = backupService.createBackup(payload.world(), payload.name()); + if (!result.success()) { + Map errorPayload = new HashMap<>(); + errorPayload.put("error", result.error()); + if (!isBlank(result.details())) { + errorPayload.put("details", result.details()); + } + + context.plugin().getLogger().warning("World backup failed for world='" + payload.world() + + "', name='" + payload.name() + "': " + + (isBlank(result.details()) ? result.error() : result.details())); + + int status; + if ("invalid_world".equals(result.error()) || "invalid_name".equals(result.error())) { + status = 400; + } else if ("world_not_found".equals(result.error())) { + status = 404; + } else { + status = 500; + } + return webRegistry.json(response, status, errorPayload); + } + + context.panelLogger().log("AUDIT", user.username(), "Created " + result.backup().world() + " backup: " + result.backup().fileName()); + return webRegistry.json(response, 200, Map.of("ok", true, "backup", toPayload(result.backup()))); + }); + + webRegistry.post("/api/extensions/world-backups/delete", PanelPermission.MANAGE_BACKUPS, (request, response, user) -> { + DeleteBackupPayload payload = gson.fromJson(request.body(), DeleteBackupPayload.class); + if (payload == null || isBlank(payload.world()) || isBlank(payload.fileName())) { + return webRegistry.json(response, 400, Map.of("error", "invalid_payload")); + } + + WorldBackupService.BackupDeleteResult result = backupService.deleteBackup(payload.world(), payload.fileName()); + if (!result.success()) { + Map errorPayload = new HashMap<>(); + errorPayload.put("error", result.error()); + if (!isBlank(result.details())) { + errorPayload.put("details", result.details()); + } + + int status; + if ("invalid_world".equals(result.error()) || "invalid_file_name".equals(result.error())) { + status = 400; + } else if ("backup_not_found".equals(result.error())) { + status = 404; + } else { + status = 500; + } + return webRegistry.json(response, status, errorPayload); + } + + context.panelLogger().log("AUDIT", user.username(), "Deleted " + result.world() + " backup: " + result.fileName()); + return webRegistry.json(response, 200, Map.of("ok", true, "fileName", result.fileName(), "world", result.world())); + }); + } + + @Override + public List navigationTabs() { + return List.of(new ExtensionNavigationTab("server", "Backups", "/dashboard/world-backups")); + } + + private Map toPayload(WorldBackupService.WorldBackupEntry backup) { + return Map.of( + "fileName", backup.fileName(), + "name", backup.name(), + "createdAt", backup.createdAt(), + "sizeBytes", backup.sizeBytes(), + "world", backup.world() + ); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private record CreateBackupPayload(String world, String name) { + } + + private record DeleteBackupPayload(String world, String fileName) { + } +} + + diff --git a/src/main/java/de/winniepat/minePanel/integrations/DiscordWebhookConfig.java b/src/main/java/de/winniepat/minePanel/integrations/DiscordWebhookConfig.java new file mode 100644 index 0000000..986f9ed --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/integrations/DiscordWebhookConfig.java @@ -0,0 +1,37 @@ +package de.winniepat.minePanel.integrations; + +public record DiscordWebhookConfig( + boolean enabled, + String webhookUrl, + boolean useEmbed, + String botName, + String messageTemplate, + String embedTitleTemplate, + boolean logChat, + boolean logCommands, + boolean logAuth, + boolean logAudit, + boolean logSecurity, + boolean logConsoleResponse, + boolean logSystem +) { + + public static DiscordWebhookConfig defaults() { + return new DiscordWebhookConfig( + false, + "", + true, + "MinePanel", + "[{timestamp}] [{kind}] [{source}] {message}", + "MinePanel {kind}", + true, + true, + true, + true, + true, + true, + true + ); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/integrations/DiscordWebhookRepository.java b/src/main/java/de/winniepat/minePanel/integrations/DiscordWebhookRepository.java new file mode 100644 index 0000000..971f9fa --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/integrations/DiscordWebhookRepository.java @@ -0,0 +1,94 @@ +package de.winniepat.minePanel.integrations; + +import de.winniepat.minePanel.persistence.Database; + +import java.sql.*; + +public final class DiscordWebhookRepository { + + private final Database database; + + public DiscordWebhookRepository(Database database) { + this.database = database; + } + + public DiscordWebhookConfig load() { + String sql = "SELECT enabled, webhook_url, use_embed, bot_name, message_template, embed_title_template, " + + "log_chat, log_commands, log_auth, log_audit, log_security, log_console_response, log_system " + + "FROM discord_webhook_config WHERE id = 1"; + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return new DiscordWebhookConfig( + resultSet.getInt("enabled") == 1, + resultSet.getString("webhook_url"), + resultSet.getInt("use_embed") == 1, + resultSet.getString("bot_name"), + resultSet.getString("message_template"), + resultSet.getString("embed_title_template"), + resultSet.getInt("log_chat") == 1, + resultSet.getInt("log_commands") == 1, + resultSet.getInt("log_auth") == 1, + resultSet.getInt("log_audit") == 1, + resultSet.getInt("log_security") == 1, + resultSet.getInt("log_console_response") == 1, + resultSet.getInt("log_system") == 1 + ); + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not load Discord webhook config", exception); + } + + DiscordWebhookConfig defaults = DiscordWebhookConfig.defaults(); + save(defaults); + return defaults; + } + + public void save(DiscordWebhookConfig config) { + String sql = "INSERT INTO discord_webhook_config(" + + "id, enabled, webhook_url, use_embed, bot_name, message_template, embed_title_template, " + + "log_chat, log_commands, log_auth, log_audit, log_security, log_console_response, log_system" + + ") VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(id) DO UPDATE SET " + + "enabled=excluded.enabled, " + + "webhook_url=excluded.webhook_url, " + + "use_embed=excluded.use_embed, " + + "bot_name=excluded.bot_name, " + + "message_template=excluded.message_template, " + + "embed_title_template=excluded.embed_title_template, " + + "log_chat=excluded.log_chat, " + + "log_commands=excluded.log_commands, " + + "log_auth=excluded.log_auth, " + + "log_audit=excluded.log_audit, " + + "log_security=excluded.log_security, " + + "log_console_response=excluded.log_console_response, " + + "log_system=excluded.log_system"; + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, config.enabled() ? 1 : 0); + statement.setString(2, nullSafe(config.webhookUrl())); + statement.setInt(3, config.useEmbed() ? 1 : 0); + statement.setString(4, nullSafe(config.botName())); + statement.setString(5, nullSafe(config.messageTemplate())); + statement.setString(6, nullSafe(config.embedTitleTemplate())); + statement.setInt(7, config.logChat() ? 1 : 0); + statement.setInt(8, config.logCommands() ? 1 : 0); + statement.setInt(9, config.logAuth() ? 1 : 0); + statement.setInt(10, config.logAudit() ? 1 : 0); + statement.setInt(11, config.logSecurity() ? 1 : 0); + statement.setInt(12, config.logConsoleResponse() ? 1 : 0); + statement.setInt(13, config.logSystem() ? 1 : 0); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not save Discord webhook config", exception); + } + } + + private String nullSafe(String value) { + return value == null ? "" : value; + } +} + diff --git a/src/main/java/de/winniepat/minePanel/integrations/DiscordWebhookService.java b/src/main/java/de/winniepat/minePanel/integrations/DiscordWebhookService.java new file mode 100644 index 0000000..bed7cbc --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/integrations/DiscordWebhookService.java @@ -0,0 +1,136 @@ +package de.winniepat.minePanel.integrations; + +import com.google.gson.Gson; + +import java.net.URI; +import java.net.http.*; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +public final class DiscordWebhookService { + + private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ISO_INSTANT; + + private final Logger logger; + private final DiscordWebhookRepository repository; + private final AtomicReference configReference; + private final ExecutorService senderExecutor; + private final HttpClient httpClient; + private final Gson gson; + + public DiscordWebhookService(Logger logger, DiscordWebhookRepository repository) { + this.logger = logger; + this.repository = repository; + this.configReference = new AtomicReference<>(repository.load()); + this.senderExecutor = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "minepanel-discord-webhook"); + thread.setDaemon(true); + return thread; + }); + this.httpClient = HttpClient.newHttpClient(); + this.gson = new Gson(); + } + + public DiscordWebhookConfig getConfig() { + return configReference.get(); + } + + public DiscordWebhookConfig updateConfig(DiscordWebhookConfig config) { + repository.save(config); + configReference.set(config); + return config; + } + + public void handlePanelLog(String kind, String source, String message, Instant timestamp) { + DiscordWebhookConfig config = configReference.get(); + if (!config.enabled() || config.webhookUrl().isBlank()) { + return; + } + + if (!shouldSend(config, kind)) { + return; + } + + String renderedMessage = renderTemplate(config.messageTemplate(), kind, source, message, timestamp); + String botName = config.botName().isBlank() ? "MinePanel" : config.botName(); + + senderExecutor.execute(() -> send(config, botName, kind, renderedMessage, source, timestamp)); + } + + public void shutdown() { + senderExecutor.shutdownNow(); + } + + private boolean shouldSend(DiscordWebhookConfig config, String kind) { + return switch (kind) { + case "CHAT" -> config.logChat(); + case "COMMAND", "CONSOLE_COMMAND" -> config.logCommands(); + case "AUTH" -> config.logAuth(); + case "AUDIT" -> config.logAudit(); + case "SECURITY" -> config.logSecurity(); + case "CONSOLE_RESPONSE" -> config.logConsoleResponse(); + case "SYSTEM" -> config.logSystem(); + default -> false; + }; + } + + private void send(DiscordWebhookConfig config, String botName, String kind, String renderedMessage, String source, Instant timestamp) { + try { + Map payload; + if (config.useEmbed()) { + String title = renderTemplate(config.embedTitleTemplate(), kind, source, renderedMessage, timestamp); + payload = Map.of( + "username", botName, + "embeds", List.of(Map.of( + "title", truncate(title, 256), + "description", truncate(renderedMessage, 4096), + "timestamp", timestamp.toString(), + "color", 3447003 + )) + ); + } else { + payload = Map.of( + "username", botName, + "content", truncate(renderedMessage, 2000) + ); + } + + HttpRequest request = HttpRequest.newBuilder(URI.create(config.webhookUrl())) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload), StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() >= 300) { + logger.warning("Discord webhook returned status " + response.statusCode()); + } + } catch (Exception exception) { + logger.warning("Discord webhook send failed: " + exception.getMessage()); + } + } + + private String renderTemplate(String template, String kind, String source, String message, Instant timestamp) { + String rawTemplate = (template == null || template.isBlank()) + ? "[{timestamp}] [{kind}] [{source}] {message}" + : template; + + return rawTemplate + .replace("{timestamp}", TIMESTAMP_FORMAT.format(timestamp)) + .replace("{kind}", kind == null ? "" : kind) + .replace("{source}", source == null ? "" : source) + .replace("{message}", message == null ? "" : message); + } + + private String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value == null ? "" : value; + } + return value.substring(0, maxLength - 1) + "..."; + } +} + diff --git a/src/main/java/de/winniepat/minePanel/lifecycle/PluginLifecycleSupport.java b/src/main/java/de/winniepat/minePanel/lifecycle/PluginLifecycleSupport.java new file mode 100644 index 0000000..14fa7f4 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/lifecycle/PluginLifecycleSupport.java @@ -0,0 +1,136 @@ +package de.winniepat.minePanel.lifecycle; + +import de.winniepat.minePanel.MinePanel; +import de.winniepat.minePanel.logs.*; +import de.winniepat.minePanel.persistence.*; +import de.winniepat.minePanel.web.BootstrapService; +import net.kyori.adventure.text.logger.slf4j.ComponentLogger; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.*; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.logging.*; + +public final class PluginLifecycleSupport { + + private static final DateTimeFormatter EXPORT_FILE_NAME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); + + private PluginLifecycleSupport() { + } + + @SuppressWarnings("all") + public static void configureThirdPartyStartupLogging() { + System.setProperty("org.eclipse.jetty.util.log.announce", "false"); + System.setProperty("org.eclipse.jetty.LEVEL", "WARN"); + + Logger.getLogger("spark").setLevel(Level.WARNING); + Logger.getLogger("org.eclipse.jetty").setLevel(Level.WARNING); + Logger.getLogger("org.eclipse.jetty.util.log").setLevel(Level.WARNING); + + try { + Class levelClass = Class.forName("org.apache.logging.log4j.Level"); + Class configuratorClass = Class.forName("org.apache.logging.log4j.core.config.Configurator"); + Method setLevelMethod = configuratorClass.getMethod("setLevel", String.class, levelClass); + Object warnLevel = levelClass.getField("WARN").get(null); + + setLevelMethod.invoke(null, "spark", warnLevel); + setLevelMethod.invoke(null, "org.eclipse.jetty", warnLevel); + setLevelMethod.invoke(null, "org.eclipse.jetty.util.log", warnLevel); + } catch (ReflectiveOperationException ignored) { + + } + } + + public static void announceMinePanelBanner(JavaPlugin plugin, ComponentLogger componentLogger, MiniMessage miniMessage) { + plugin.getLogger().info(""); + componentLogger.info(miniMessage.deserialize(" __ __ ____ ")); + componentLogger.info(miniMessage.deserialize("| \\/ | | _ \\ ")); + componentLogger.info("{}{}", miniMessage.deserialize("| |\\/| | | |_) | MinePanel: "), miniMessage.deserialize("" + plugin.getDescription().getVersion() + "")); + componentLogger.info("{}{}", miniMessage.deserialize("| | | | | __/ Running on: "), miniMessage.deserialize("" + plugin.getServer().getName() + "" + "(" + plugin.getServer().getMinecraftVersion() + ")")); + componentLogger.info(miniMessage.deserialize("|_| |_| |_| ")); + plugin.getLogger().info(""); + } + + public static void announceBootstrapToken(BootstrapService bootstrapService, ComponentLogger componentLogger, MiniMessage miniMessage) { + bootstrapService.getBootstrapToken().ifPresent(token -> { + componentLogger.info("{}{}", miniMessage.deserialize("First launch setup token: "), token); + componentLogger.info(""); + }); + } + + public static void registerPluginListeners( + MinePanel plugin, + PanelLogger panelLogger, + KnownPlayerRepository knownPlayerRepository, + PlayerActivityRepository playerActivityRepository, + JoinLeaveEventRepository joinLeaveEventRepository + ) { + plugin.getServer().getPluginManager().registerEvents(new ChatCaptureListener(panelLogger), plugin); + plugin.getServer().getPluginManager().registerEvents(new CommandCaptureListener(panelLogger), plugin); + plugin.getServer().getPluginManager().registerEvents(new PlayerActivityListener(plugin, knownPlayerRepository, playerActivityRepository, joinLeaveEventRepository, panelLogger), plugin); + } + + public static void synchronizeKnownPlayers(Server server, KnownPlayerRepository knownPlayerRepository, PlayerActivityRepository playerActivityRepository) { + for (OfflinePlayer offlinePlayer : server.getOfflinePlayers()) { + if (offlinePlayer.getName() != null) { + knownPlayerRepository.upsert(offlinePlayer.getUniqueId(), offlinePlayer.getName()); + playerActivityRepository.ensureFromOffline( + offlinePlayer.getUniqueId(), + Math.max(0L, offlinePlayer.getFirstPlayed()), + Math.max(0L, offlinePlayer.getLastPlayed()) + ); + } + } + + server.getOnlinePlayers().forEach(player -> { + knownPlayerRepository.upsert(player.getUniqueId(), player.getName()); + long now = Instant.now().toEpochMilli(); + playerActivityRepository.onJoin( + player.getUniqueId(), + now, + player.getAddress() != null && player.getAddress().getAddress() != null + ? player.getAddress().getAddress().getHostAddress() + : "" + ); + }); + } + + public static void exportPanelLogsToServerLogsDirectory(Path dataFolder, LogRepository logRepository, Logger logger) { + try { + Path logsDirectory = dataFolder.resolve("logs"); + Files.createDirectories(logsDirectory); + + String timestamp = EXPORT_FILE_NAME_FORMAT.format(Instant.now().atZone(ZoneOffset.UTC)); + Path exportFile = logsDirectory.resolve("minepanel-panel-" + timestamp + ".log"); + + StringBuilder output = new StringBuilder(); + for (var entry : logRepository.allLogsAscending()) { + output.append(Instant.ofEpochMilli(entry.createdAt())) + .append(" [") + .append(entry.kind()) + .append("] [") + .append(entry.source()) + .append("] ") + .append(entry.message()) + .append(System.lineSeparator()); + } + + if (output.isEmpty()) { + output.append(Instant.now()).append(" [SYSTEM] [PLUGIN] No panel logs available during shutdown export").append(System.lineSeparator()); + } + + Files.writeString(exportFile, output, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + + logger.info("Exported panel logs to " + exportFile.toAbsolutePath()); + } catch (IOException | IllegalStateException exception) { + logger.warning("Could not export panel logs on disable: " + exception.getMessage()); + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/logs/ChatCaptureListener.java b/src/main/java/de/winniepat/minePanel/logs/ChatCaptureListener.java new file mode 100644 index 0000000..18822b9 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/logs/ChatCaptureListener.java @@ -0,0 +1,23 @@ +package de.winniepat.minePanel.logs; + +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.event.*; + +public final class ChatCaptureListener implements Listener { + + private static final PlainTextComponentSerializer PLAIN_TEXT_SERIALIZER = PlainTextComponentSerializer.plainText(); + + private final PanelLogger panelLogger; + + public ChatCaptureListener(PanelLogger panelLogger) { + this.panelLogger = panelLogger; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChat(AsyncChatEvent event) { + String message = PLAIN_TEXT_SERIALIZER.serialize(event.message()); + panelLogger.log("CHAT", event.getPlayer().getName(), message); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/logs/CommandCaptureListener.java b/src/main/java/de/winniepat/minePanel/logs/CommandCaptureListener.java new file mode 100644 index 0000000..b19b479 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/logs/CommandCaptureListener.java @@ -0,0 +1,25 @@ +package de.winniepat.minePanel.logs; + +import org.bukkit.event.*; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.server.ServerCommandEvent; + +public final class CommandCaptureListener implements Listener { + + private final PanelLogger panelLogger; + + public CommandCaptureListener(PanelLogger panelLogger) { + this.panelLogger = panelLogger; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerCommand(PlayerCommandPreprocessEvent event) { + panelLogger.log("COMMAND", event.getPlayer().getName(), event.getMessage()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onServerCommand(ServerCommandEvent event) { + panelLogger.log("CONSOLE_COMMAND", "CONSOLE", event.getCommand()); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/logs/PanelLogEntry.java b/src/main/java/de/winniepat/minePanel/logs/PanelLogEntry.java new file mode 100644 index 0000000..bd8dbd1 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/logs/PanelLogEntry.java @@ -0,0 +1,11 @@ +package de.winniepat.minePanel.logs; + +public record PanelLogEntry( + long id, + String kind, + String source, + String message, + long createdAt +) { +} + diff --git a/src/main/java/de/winniepat/minePanel/logs/PanelLogger.java b/src/main/java/de/winniepat/minePanel/logs/PanelLogger.java new file mode 100644 index 0000000..3b23915 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/logs/PanelLogger.java @@ -0,0 +1,26 @@ +package de.winniepat.minePanel.logs; + +import de.winniepat.minePanel.integrations.DiscordWebhookService; +import de.winniepat.minePanel.persistence.LogRepository; + +import java.time.Instant; + +public final class PanelLogger { + + private final LogRepository logRepository; + private final DiscordWebhookService discordWebhookService; + + public PanelLogger(LogRepository logRepository, DiscordWebhookService discordWebhookService) { + this.logRepository = logRepository; + this.discordWebhookService = discordWebhookService; + } + + public synchronized void log(String kind, String source, String message) { + Instant now = Instant.now(); + String safeMessage = message == null ? "" : message; + logRepository.appendLog(kind, source, safeMessage); + + discordWebhookService.handlePanelLog(kind, source, safeMessage, now); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/logs/PlayerActivityListener.java b/src/main/java/de/winniepat/minePanel/logs/PlayerActivityListener.java new file mode 100644 index 0000000..8e643c6 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/logs/PlayerActivityListener.java @@ -0,0 +1,144 @@ +package de.winniepat.minePanel.logs; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import de.winniepat.minePanel.MinePanel; +import de.winniepat.minePanel.persistence.*; +import org.bukkit.entity.Player; +import org.bukkit.event.*; +import org.bukkit.event.player.*; + +import java.net.*; +import java.net.http.*; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.UUID; + +public final class PlayerActivityListener implements Listener { + + private final MinePanel plugin; + private final KnownPlayerRepository knownPlayerRepository; + private final PlayerActivityRepository playerActivityRepository; + private final JoinLeaveEventRepository joinLeaveEventRepository; + private final PanelLogger panelLogger; + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final Gson gson = new Gson(); + + public PlayerActivityListener( + MinePanel plugin, + KnownPlayerRepository knownPlayerRepository, + PlayerActivityRepository playerActivityRepository, + JoinLeaveEventRepository joinLeaveEventRepository, + PanelLogger panelLogger + ) { + this.plugin = plugin; + this.knownPlayerRepository = knownPlayerRepository; + this.playerActivityRepository = playerActivityRepository; + this.joinLeaveEventRepository = joinLeaveEventRepository; + this.panelLogger = panelLogger; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + UUID uuid = player.getUniqueId(); + long now = Instant.now().toEpochMilli(); + + knownPlayerRepository.upsert(uuid, player.getName(), now); + joinLeaveEventRepository.appendJoinEvent(uuid, player.getName(), now); + + String ip = ""; + if (player.getAddress() != null && player.getAddress().getAddress() != null) { + ip = player.getAddress().getAddress().getHostAddress(); + } + playerActivityRepository.onJoin(uuid, now, ip); + + if (!ip.isBlank()) { + Player matchingOnlinePlayer = findOtherOnlinePlayerWithSameIp(player, ip); + if (matchingOnlinePlayer != null) { + panelLogger.log( + "SECURITY", + "ALT_DETECT", + "Possible alt account: " + player.getName() + + " joined with IP " + ip + + " already used by online player " + matchingOnlinePlayer.getName() + ); + } + } + + if (!ip.isBlank()) { + String ipAddress = ip; + plugin.schedulerBridge().runAsync(() -> { + String country = resolveCountry(ipAddress); + playerActivityRepository.updateCountry(uuid, country); + }); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerQuit(PlayerQuitEvent event) { + Player player = event.getPlayer(); + long now = Instant.now().toEpochMilli(); + knownPlayerRepository.upsert(player.getUniqueId(), player.getName(), now); + playerActivityRepository.onQuit(player.getUniqueId(), now); + joinLeaveEventRepository.appendLeaveEvent(player.getUniqueId(), player.getName(), now); + } + + private String resolveCountry(String ipAddress) { + if (isLocalAddress(ipAddress)) { + return "Local"; + } + + try { + String encodedIp = URLEncoder.encode(ipAddress, StandardCharsets.UTF_8); + String url = "http://ip-api.com/json/" + encodedIp + "?fields=status,country"; + HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() >= 300) { + return "Unknown"; + } + + JsonObject payload = gson.fromJson(response.body(), JsonObject.class); + if (payload == null || !payload.has("status")) { + return "Unknown"; + } + if (!"success".equalsIgnoreCase(payload.get("status").getAsString())) { + return "Unknown"; + } + if (!payload.has("country") || payload.get("country").isJsonNull()) { + return "Unknown"; + } + return payload.get("country").getAsString(); + } catch (Exception ignored) { + return "Unknown"; + } + } + + private boolean isLocalAddress(String ipAddress) { + try { + InetAddress address = InetAddress.getByName(ipAddress); + return address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isSiteLocalAddress(); + } catch (Exception ignored) { + return true; + } + } + + private Player findOtherOnlinePlayerWithSameIp(Player joinedPlayer, String ipAddress) { + for (Player online : plugin.getServer().getOnlinePlayers()) { + if (online.getUniqueId().equals(joinedPlayer.getUniqueId())) { + continue; + } + + String onlineIp = ""; + if (online.getAddress() != null && online.getAddress().getAddress() != null) { + onlineIp = online.getAddress().getAddress().getHostAddress(); + } + + if (!onlineIp.isBlank() && onlineIp.equals(ipAddress)) { + return online; + } + } + return null; + } +} + diff --git a/src/main/java/de/winniepat/minePanel/logs/ServerLogService.java b/src/main/java/de/winniepat/minePanel/logs/ServerLogService.java new file mode 100644 index 0000000..14e528b --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/logs/ServerLogService.java @@ -0,0 +1,88 @@ +package de.winniepat.minePanel.logs; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.stream.*; + +public final class ServerLogService { + + private final Path logDirectory; + private final Path latestLogFile; + + public ServerLogService(Path pluginDataFolder) { + Path pluginsFolder = pluginDataFolder.getParent(); + Path serverRoot = pluginsFolder == null ? pluginDataFolder : pluginsFolder.getParent(); + if (serverRoot == null) { + serverRoot = pluginDataFolder; + } + this.logDirectory = serverRoot.resolve("logs"); + this.latestLogFile = logDirectory.resolve("latest.log"); + } + + public List readLatestLines(int lines) { + return readLogLines("latest.log", lines); + } + + public List readLogLines(String logFileName, int lines) { + Path targetFile = resolveLogFile(logFileName); + if (targetFile == null || !Files.exists(targetFile)) { + return Collections.emptyList(); + } + + int safeLines = Math.max(1, Math.min(lines, 2000)); + Deque queue = new ArrayDeque<>(safeLines); + + try { + for (String line : Files.readAllLines(targetFile, StandardCharsets.UTF_8)) { + if (queue.size() == safeLines) { + queue.removeFirst(); + } + queue.addLast(line); + } + } catch (IOException exception) { + return List.of("Unable to read " + targetFile.getFileName() + ": " + exception.getMessage()); + } + + return new ArrayList<>(queue); + } + + public List listLogFiles() { + if (!Files.exists(logDirectory) || !Files.isDirectory(logDirectory)) { + return Collections.emptyList(); + } + + try (Stream files = Files.list(logDirectory)) { + return files + .filter(Files::isRegularFile) + .sorted(Comparator.comparingLong(this::lastModifiedSafe).reversed()) + .map(path -> path.getFileName().toString()) + .collect(Collectors.toList()); + } catch (IOException exception) { + return Collections.emptyList(); + } + } + + private Path resolveLogFile(String logFileName) { + String selected = (logFileName == null || logFileName.isBlank()) ? "latest.log" : logFileName; + if (selected.contains("/") || selected.contains("\\")) { + return null; + } + + Path target = logDirectory.resolve(selected).normalize(); + if (!target.startsWith(logDirectory)) { + return null; + } + return target; + } + + private long lastModifiedSafe(Path path) { + try { + return Files.getLastModifiedTime(path).toMillis(); + } catch (IOException ignored) { + return Long.MIN_VALUE; + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/persistence/Database.java b/src/main/java/de/winniepat/minePanel/persistence/Database.java new file mode 100644 index 0000000..7b61c08 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/persistence/Database.java @@ -0,0 +1,143 @@ +package de.winniepat.minePanel.persistence; + +import java.nio.file.*; +import java.sql.*; + +public final class Database { + + private final Path databaseFile; + + public Database(Path databaseFile) { + this.databaseFile = databaseFile; + } + + public void initialize() { + try { + Files.createDirectories(databaseFile.getParent()); + } catch (Exception exception) { + throw new IllegalStateException("Could not create database directory", exception); + } + + try (Connection connection = getConnection(); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE IF NOT EXISTS users (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "username TEXT NOT NULL UNIQUE," + + "password_hash TEXT NOT NULL," + + "role TEXT NOT NULL," + + "created_at INTEGER NOT NULL" + + ")"); + + statement.execute("CREATE TABLE IF NOT EXISTS user_permissions (" + + "user_id INTEGER NOT NULL," + + "permission TEXT NOT NULL," + + "PRIMARY KEY(user_id, permission)," + + "FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE" + + ")"); + + statement.execute("CREATE TABLE IF NOT EXISTS sessions (" + + "token TEXT PRIMARY KEY," + + "user_id INTEGER NOT NULL," + + "created_at INTEGER NOT NULL," + + "expires_at INTEGER NOT NULL," + + "FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE" + + ")"); + + statement.execute("CREATE TABLE IF NOT EXISTS oauth_accounts (" + + "user_id INTEGER NOT NULL," + + "provider TEXT NOT NULL," + + "provider_user_id TEXT NOT NULL," + + "display_name TEXT NOT NULL DEFAULT ''," + + "email TEXT NOT NULL DEFAULT ''," + + "avatar_url TEXT NOT NULL DEFAULT ''," + + "linked_at INTEGER NOT NULL," + + "updated_at INTEGER NOT NULL," + + "PRIMARY KEY(user_id, provider)," + + "UNIQUE(provider, provider_user_id)," + + "FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE" + + ")"); + + statement.execute("CREATE TABLE IF NOT EXISTS oauth_states (" + + "state TEXT PRIMARY KEY," + + "provider TEXT NOT NULL," + + "mode TEXT NOT NULL," + + "user_id INTEGER," + + "created_at INTEGER NOT NULL," + + "expires_at INTEGER NOT NULL" + + ")"); + + statement.execute("CREATE TABLE IF NOT EXISTS panel_logs (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "kind TEXT NOT NULL," + + "source TEXT NOT NULL," + + "message TEXT NOT NULL," + + "created_at INTEGER NOT NULL" + + ")"); + + statement.execute("CREATE TABLE IF NOT EXISTS known_players (" + + "uuid TEXT PRIMARY KEY," + + "username TEXT NOT NULL," + + "last_seen_at INTEGER NOT NULL" + + ")"); + + statement.execute("CREATE TABLE IF NOT EXISTS player_activity (" + + "uuid TEXT PRIMARY KEY," + + "first_joined INTEGER NOT NULL DEFAULT 0," + + "last_seen INTEGER NOT NULL DEFAULT 0," + + "total_playtime_seconds INTEGER NOT NULL DEFAULT 0," + + "total_sessions INTEGER NOT NULL DEFAULT 0," + + "current_session_start INTEGER NOT NULL DEFAULT 0," + + "last_ip TEXT NOT NULL DEFAULT ''," + + "last_country TEXT NOT NULL DEFAULT ''" + + ")"); + + statement.execute("CREATE TABLE IF NOT EXISTS join_leave_events (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "event_type TEXT NOT NULL," + + "player_uuid TEXT NOT NULL," + + "player_name TEXT NOT NULL," + + "created_at INTEGER NOT NULL" + + ")"); + + statement.execute("CREATE TABLE IF NOT EXISTS discord_webhook_config (" + + "id INTEGER PRIMARY KEY," + + "enabled INTEGER NOT NULL," + + "webhook_url TEXT NOT NULL," + + "use_embed INTEGER NOT NULL," + + "bot_name TEXT NOT NULL," + + "message_template TEXT NOT NULL," + + "embed_title_template TEXT NOT NULL," + + "log_chat INTEGER NOT NULL," + + "log_commands INTEGER NOT NULL," + + "log_auth INTEGER NOT NULL," + + "log_audit INTEGER NOT NULL," + + "log_security INTEGER NOT NULL DEFAULT 1," + + "log_console_response INTEGER NOT NULL," + + "log_system INTEGER NOT NULL" + + ")"); + + statement.execute("CREATE TABLE IF NOT EXISTS extension_settings (" + + "extension_id TEXT PRIMARY KEY," + + "settings_json TEXT NOT NULL DEFAULT '{}'," + + "updated_at INTEGER NOT NULL DEFAULT 0" + + ")"); + + try { + statement.execute("ALTER TABLE discord_webhook_config ADD COLUMN log_security INTEGER NOT NULL DEFAULT 1"); + } catch (SQLException ignored) { + // Column already exists on upgraded installs. + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not initialize database", exception); + } + } + + public Connection getConnection() throws SQLException { + return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.toAbsolutePath()); + } + + public void close() { + + } +} + diff --git a/src/main/java/de/winniepat/minePanel/persistence/ExtensionSettingsRepository.java b/src/main/java/de/winniepat/minePanel/persistence/ExtensionSettingsRepository.java new file mode 100644 index 0000000..1d350cb --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/persistence/ExtensionSettingsRepository.java @@ -0,0 +1,64 @@ +package de.winniepat.minePanel.persistence; + +import java.sql.*; +import java.util.Optional; + +public final class ExtensionSettingsRepository { + + private final Database database; + + public ExtensionSettingsRepository(Database database) { + this.database = database; + } + + public Optional findSettingsJson(String extensionId) { + String normalizedId = normalizeExtensionId(extensionId); + if (normalizedId.isBlank()) { + return Optional.empty(); + } + + String sql = "SELECT settings_json FROM extension_settings WHERE extension_id = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, normalizedId); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return Optional.ofNullable(resultSet.getString("settings_json")); + } + } + return Optional.empty(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not read extension settings", exception); + } + } + + public void saveSettingsJson(String extensionId, String settingsJson, long updatedAtMillis) { + String normalizedId = normalizeExtensionId(extensionId); + if (normalizedId.isBlank()) { + throw new IllegalArgumentException("invalid_extension_id"); + } + + String sql = "INSERT INTO extension_settings(extension_id, settings_json, updated_at) VALUES (?, ?, ?) " + + "ON CONFLICT(extension_id) DO UPDATE SET " + + "settings_json = excluded.settings_json, " + + "updated_at = excluded.updated_at"; + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, normalizedId); + statement.setString(2, settingsJson == null || settingsJson.isBlank() ? "{}" : settingsJson); + statement.setLong(3, Math.max(0L, updatedAtMillis)); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not save extension settings", exception); + } + } + + private String normalizeExtensionId(String extensionId) { + if (extensionId == null || extensionId.isBlank()) { + return ""; + } + return extensionId.trim().toLowerCase(); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/persistence/JoinLeaveEventRepository.java b/src/main/java/de/winniepat/minePanel/persistence/JoinLeaveEventRepository.java new file mode 100644 index 0000000..d6b5026 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/persistence/JoinLeaveEventRepository.java @@ -0,0 +1,66 @@ +package de.winniepat.minePanel.persistence; + +import java.sql.*; +import java.util.*; + +public final class JoinLeaveEventRepository { + + private final Database database; + + public JoinLeaveEventRepository(Database database) { + this.database = database; + } + + public void appendJoinEvent(UUID uuid, String username, long createdAt) { + appendEvent("JOIN", uuid, username, createdAt); + } + + public void appendLeaveEvent(UUID uuid, String username, long createdAt) { + appendEvent("LEAVE", uuid, username, createdAt); + } + + public List listEventsSince(long sinceMillis) { + String sql = "SELECT event_type, player_uuid, player_name, created_at " + + "FROM join_leave_events WHERE created_at >= ? ORDER BY created_at ASC"; + List events = new ArrayList<>(); + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, Math.max(0L, sinceMillis)); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + events.add(new JoinLeaveEvent( + resultSet.getString("event_type"), + UUID.fromString(resultSet.getString("player_uuid")), + resultSet.getString("player_name"), + resultSet.getLong("created_at") + )); + } + } + return events; + } catch (SQLException exception) { + throw new IllegalStateException("Could not list join/leave events", exception); + } + } + + private void appendEvent(String eventType, UUID uuid, String username, long createdAt) { + if (uuid == null || username == null || username.isBlank()) { + return; + } + + String sql = "INSERT INTO join_leave_events(event_type, player_uuid, player_name, created_at) VALUES (?, ?, ?, ?)"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, eventType); + statement.setString(2, uuid.toString()); + statement.setString(3, username); + statement.setLong(4, createdAt); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not append join/leave event", exception); + } + } + + public record JoinLeaveEvent(String eventType, UUID playerUuid, String playerName, long createdAt) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/persistence/KnownPlayer.java b/src/main/java/de/winniepat/minePanel/persistence/KnownPlayer.java new file mode 100644 index 0000000..22ad00b --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/persistence/KnownPlayer.java @@ -0,0 +1,11 @@ +package de.winniepat.minePanel.persistence; + +import java.util.UUID; + +public record KnownPlayer( + UUID uuid, + String username, + long lastSeenAt +) { +} + diff --git a/src/main/java/de/winniepat/minePanel/persistence/KnownPlayerRepository.java b/src/main/java/de/winniepat/minePanel/persistence/KnownPlayerRepository.java new file mode 100644 index 0000000..2fa5500 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/persistence/KnownPlayerRepository.java @@ -0,0 +1,100 @@ +package de.winniepat.minePanel.persistence; + +import java.sql.*; +import java.time.Instant; +import java.util.*; + +public final class KnownPlayerRepository { + + private final Database database; + + public KnownPlayerRepository(Database database) { + this.database = database; + } + + public void upsert(UUID uuid, String username) { + upsert(uuid, username, Instant.now().toEpochMilli()); + } + + public void upsert(UUID uuid, String username, long lastSeenAt) { + if (uuid == null || username == null || username.isBlank()) { + return; + } + + String sql = "INSERT INTO known_players(uuid, username, last_seen_at) VALUES (?, ?, ?) " + + "ON CONFLICT(uuid) DO UPDATE SET username = excluded.username, last_seen_at = excluded.last_seen_at"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + statement.setString(2, username); + statement.setLong(3, lastSeenAt); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not upsert known player", exception); + } + } + + public List findAll() { + String sql = "SELECT uuid, username, last_seen_at FROM known_players"; + List players = new ArrayList<>(); + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + players.add(new KnownPlayer( + UUID.fromString(resultSet.getString("uuid")), + resultSet.getString("username"), + resultSet.getLong("last_seen_at") + )); + } + return players; + } catch (SQLException exception) { + throw new IllegalStateException("Could not list known players", exception); + } + } + + public Optional findByUuid(UUID uuid) { + String sql = "SELECT uuid, username, last_seen_at FROM known_players WHERE uuid = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return Optional.of(new KnownPlayer( + UUID.fromString(resultSet.getString("uuid")), + resultSet.getString("username"), + resultSet.getLong("last_seen_at") + )); + } + return Optional.empty(); + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not find known player", exception); + } + } + + public Optional findByUsername(String username) { + if (username == null || username.isBlank()) { + return Optional.empty(); + } + + String sql = "SELECT uuid, username, last_seen_at FROM known_players WHERE LOWER(username) = LOWER(?) LIMIT 1"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, username.trim()); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return Optional.of(new KnownPlayer( + UUID.fromString(resultSet.getString("uuid")), + resultSet.getString("username"), + resultSet.getLong("last_seen_at") + )); + } + return Optional.empty(); + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not find known player by username", exception); + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/persistence/LogRepository.java b/src/main/java/de/winniepat/minePanel/persistence/LogRepository.java new file mode 100644 index 0000000..519380f --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/persistence/LogRepository.java @@ -0,0 +1,97 @@ +package de.winniepat.minePanel.persistence; + +import de.winniepat.minePanel.logs.PanelLogEntry; + +import java.sql.*; +import java.time.Instant; +import java.util.*; + +public final class LogRepository { + + private final Database database; + + public LogRepository(Database database) { + this.database = database; + } + + public void appendLog(String kind, String source, String message) { + String sql = "INSERT INTO panel_logs(kind, source, message, created_at) VALUES (?, ?, ?, ?)"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, kind); + statement.setString(2, source); + statement.setString(3, message); + statement.setLong(4, Instant.now().toEpochMilli()); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not append panel log", exception); + } + } + + public List recentLogs(int limit) { + int safeLimit = Math.max(1, Math.min(limit, 1000)); + String sql = "SELECT id, kind, source, message, created_at FROM panel_logs ORDER BY id DESC LIMIT ?"; + List entries = new ArrayList<>(); + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, safeLimit); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + entries.add(new PanelLogEntry( + resultSet.getLong("id"), + resultSet.getString("kind"), + resultSet.getString("source"), + resultSet.getString("message"), + resultSet.getLong("created_at") + )); + } + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not query panel logs", exception); + } + return entries; + } + + public List allLogsAscending() { + String sql = "SELECT id, kind, source, message, created_at FROM panel_logs ORDER BY id ASC"; + List entries = new ArrayList<>(); + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + entries.add(new PanelLogEntry( + resultSet.getLong("id"), + resultSet.getString("kind"), + resultSet.getString("source"), + resultSet.getString("message"), + resultSet.getLong("created_at") + )); + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not query all panel logs", exception); + } + return entries; + } + + public long latestLogId() { + String sql = "SELECT COALESCE(MAX(id), 0) AS latest_id FROM panel_logs"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + return resultSet.getLong("latest_id"); + } catch (SQLException exception) { + throw new IllegalStateException("Could not get latest panel log id", exception); + } + } + + public void clearLogs() { + String sql = "DELETE FROM panel_logs"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not clear panel logs", exception); + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/persistence/OAuthAccountRepository.java b/src/main/java/de/winniepat/minePanel/persistence/OAuthAccountRepository.java new file mode 100644 index 0000000..72db057 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/persistence/OAuthAccountRepository.java @@ -0,0 +1,153 @@ +package de.winniepat.minePanel.persistence; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +public final class OAuthAccountRepository { + + private final Database database; + + public OAuthAccountRepository(Database database) { + this.database = database; + } + + public Optional findUserIdByProviderSubject(String provider, String providerUserId) { + String normalizedProvider = normalizeProvider(provider); + String sql = "SELECT user_id FROM oauth_accounts WHERE provider = ? AND provider_user_id = ?"; + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, normalizedProvider); + statement.setString(2, providerUserId); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return Optional.of(resultSet.getLong("user_id")); + } + } + return Optional.empty(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not query OAuth account by provider subject", exception); + } + } + + public Optional findByUserAndProvider(long userId, String provider) { + String normalizedProvider = normalizeProvider(provider); + String sql = "SELECT user_id, provider, provider_user_id, display_name, email, avatar_url, linked_at, updated_at " + + "FROM oauth_accounts WHERE user_id = ? AND provider = ?"; + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, userId); + statement.setString(2, normalizedProvider); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return Optional.of(map(resultSet)); + } + } + return Optional.empty(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not query OAuth account by user and provider", exception); + } + } + + public List listByUserId(long userId) { + String sql = "SELECT user_id, provider, provider_user_id, display_name, email, avatar_url, linked_at, updated_at " + + "FROM oauth_accounts WHERE user_id = ? ORDER BY provider ASC"; + + List links = new ArrayList<>(); + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, userId); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + links.add(map(resultSet)); + } + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not list OAuth links for user", exception); + } + return links; + } + + public void upsertLink(long userId, String provider, String providerUserId, String displayName, String email, String avatarUrl) { + String normalizedProvider = normalizeProvider(provider); + long now = Instant.now().toEpochMilli(); + String sql = "INSERT INTO oauth_accounts(user_id, provider, provider_user_id, display_name, email, avatar_url, linked_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(user_id, provider) DO UPDATE SET " + + "provider_user_id = excluded.provider_user_id, " + + "display_name = excluded.display_name, " + + "email = excluded.email, " + + "avatar_url = excluded.avatar_url, " + + "updated_at = excluded.updated_at"; + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, userId); + statement.setString(2, normalizedProvider); + statement.setString(3, providerUserId); + statement.setString(4, safe(displayName)); + statement.setString(5, safe(email)); + statement.setString(6, safe(avatarUrl)); + statement.setLong(7, now); + statement.setLong(8, now); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not upsert OAuth account link", exception); + } + } + + public boolean unlink(long userId, String provider) { + String normalizedProvider = normalizeProvider(provider); + String sql = "DELETE FROM oauth_accounts WHERE user_id = ? AND provider = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, userId); + statement.setString(2, normalizedProvider); + return statement.executeUpdate() > 0; + } catch (SQLException exception) { + throw new IllegalStateException("Could not unlink OAuth account", exception); + } + } + + private OAuthAccountLink map(ResultSet resultSet) throws SQLException { + return new OAuthAccountLink( + resultSet.getLong("user_id"), + resultSet.getString("provider"), + resultSet.getString("provider_user_id"), + resultSet.getString("display_name"), + resultSet.getString("email"), + resultSet.getString("avatar_url"), + resultSet.getLong("linked_at"), + resultSet.getLong("updated_at") + ); + } + + private String normalizeProvider(String provider) { + return provider == null ? "" : provider.trim().toLowerCase(Locale.ROOT); + } + + private String safe(String value) { + return value == null ? "" : value.trim(); + } + + public record OAuthAccountLink( + long userId, + String provider, + String providerUserId, + String displayName, + String email, + String avatarUrl, + long linkedAt, + long updatedAt + ) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/persistence/OAuthStateRepository.java b/src/main/java/de/winniepat/minePanel/persistence/OAuthStateRepository.java new file mode 100644 index 0000000..82ad0bf --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/persistence/OAuthStateRepository.java @@ -0,0 +1,122 @@ +package de.winniepat.minePanel.persistence; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.Locale; +import java.util.Optional; + +public final class OAuthStateRepository { + + private final Database database; + + public OAuthStateRepository(Database database) { + this.database = database; + } + + public void createState(String state, String provider, String mode, Long userId, long expiresAtMillis) { + cleanupExpired(); + + String sql = "INSERT INTO oauth_states(state, provider, mode, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)"; + long now = Instant.now().toEpochMilli(); + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, state); + statement.setString(2, normalize(provider)); + statement.setString(3, normalize(mode)); + if (userId == null) { + statement.setNull(4, java.sql.Types.INTEGER); + } else { + statement.setLong(4, userId); + } + statement.setLong(5, now); + statement.setLong(6, expiresAtMillis); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not create OAuth state", exception); + } + } + + public Optional consumeState(String state, String provider, String mode) { + String normalizedProvider = normalize(provider); + String normalizedMode = normalize(mode); + String selectSql = "SELECT state, provider, mode, user_id, created_at, expires_at FROM oauth_states WHERE state = ? AND provider = ? AND mode = ?"; + String deleteSql = "DELETE FROM oauth_states WHERE state = ?"; + + try (Connection connection = database.getConnection(); + PreparedStatement selectStatement = connection.prepareStatement(selectSql); + PreparedStatement deleteStatement = connection.prepareStatement(deleteSql)) { + connection.setAutoCommit(false); + + selectStatement.setString(1, state); + selectStatement.setString(2, normalizedProvider); + selectStatement.setString(3, normalizedMode); + + OAuthState oauthState = null; + try (ResultSet resultSet = selectStatement.executeQuery()) { + if (resultSet.next()) { + Long userId = null; + long rawUserId = resultSet.getLong("user_id"); + if (!resultSet.wasNull()) { + userId = rawUserId; + } + oauthState = new OAuthState( + resultSet.getString("state"), + resultSet.getString("provider"), + resultSet.getString("mode"), + userId, + resultSet.getLong("created_at"), + resultSet.getLong("expires_at") + ); + } + } + + if (oauthState == null) { + connection.rollback(); + return Optional.empty(); + } + + deleteStatement.setString(1, state); + deleteStatement.executeUpdate(); + + if (oauthState.expiresAtMillis() <= Instant.now().toEpochMilli()) { + connection.commit(); + return Optional.empty(); + } + + connection.commit(); + return Optional.of(oauthState); + } catch (SQLException exception) { + throw new IllegalStateException("Could not consume OAuth state", exception); + } + } + + public void cleanupExpired() { + String sql = "DELETE FROM oauth_states WHERE expires_at <= ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, Instant.now().toEpochMilli()); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not cleanup OAuth states", exception); + } + } + + private String normalize(String raw) { + return raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT); + } + + public record OAuthState( + String state, + String provider, + String mode, + Long userId, + long createdAtMillis, + long expiresAtMillis + ) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/persistence/PlayerActivity.java b/src/main/java/de/winniepat/minePanel/persistence/PlayerActivity.java new file mode 100644 index 0000000..02d70fb --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/persistence/PlayerActivity.java @@ -0,0 +1,16 @@ +package de.winniepat.minePanel.persistence; + +import java.util.UUID; + +public record PlayerActivity( + UUID uuid, + long firstJoined, + long lastSeen, + long totalPlaytimeSeconds, + long totalSessions, + long currentSessionStart, + String lastIp, + String lastCountry +) { +} + diff --git a/src/main/java/de/winniepat/minePanel/persistence/PlayerActivityRepository.java b/src/main/java/de/winniepat/minePanel/persistence/PlayerActivityRepository.java new file mode 100644 index 0000000..a211837 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/persistence/PlayerActivityRepository.java @@ -0,0 +1,152 @@ +package de.winniepat.minePanel.persistence; + +import java.sql.*; +import java.util.*; + +public final class PlayerActivityRepository { + + private final Database database; + + public PlayerActivityRepository(Database database) { + this.database = database; + } + + public void ensureFromOffline(UUID uuid, long firstJoined, long lastSeen) { + if (uuid == null) { + return; + } + + long safeFirstJoined = Math.max(0L, firstJoined); + long safeLastSeen = Math.max(0L, lastSeen); + String sql = "INSERT INTO player_activity(uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country) " + + "VALUES (?, ?, ?, 0, 0, 0, '', '') " + + "ON CONFLICT(uuid) DO UPDATE SET " + + "first_joined = CASE WHEN player_activity.first_joined = 0 THEN excluded.first_joined ELSE player_activity.first_joined END, " + + "last_seen = MAX(player_activity.last_seen, excluded.last_seen)"; + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + statement.setLong(2, safeFirstJoined); + statement.setLong(3, safeLastSeen); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not seed player activity", exception); + } + } + + public void onJoin(UUID uuid, long joinedAt, String ipAddress) { + if (uuid == null) { + return; + } + + String safeIp = ipAddress == null ? "" : ipAddress; + String sql = "INSERT INTO player_activity(uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country) " + + "VALUES (?, ?, ?, 0, 1, ?, ?, '') " + + "ON CONFLICT(uuid) DO UPDATE SET " + + "first_joined = CASE WHEN player_activity.first_joined = 0 THEN excluded.first_joined ELSE player_activity.first_joined END, " + + "last_seen = excluded.last_seen, " + + "total_sessions = player_activity.total_sessions + 1, " + + "current_session_start = CASE WHEN player_activity.current_session_start > 0 THEN player_activity.current_session_start ELSE excluded.current_session_start END, " + + "last_ip = excluded.last_ip"; + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + statement.setLong(2, joinedAt); + statement.setLong(3, joinedAt); + statement.setLong(4, joinedAt); + statement.setString(5, safeIp); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not update player activity on join", exception); + } + } + + public void onQuit(UUID uuid, long quitAt) { + if (uuid == null) { + return; + } + + String sql = "UPDATE player_activity SET " + + "last_seen = ?, " + + "total_playtime_seconds = total_playtime_seconds + CASE " + + "WHEN current_session_start > 0 AND ? > current_session_start THEN (? - current_session_start) / 1000 ELSE 0 END, " + + "current_session_start = 0 " + + "WHERE uuid = ?"; + + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, quitAt); + statement.setLong(2, quitAt); + statement.setLong(3, quitAt); + statement.setString(4, uuid.toString()); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not update player activity on quit", exception); + } + } + + public void updateCountry(UUID uuid, String country) { + if (uuid == null) { + return; + } + + String sql = "UPDATE player_activity SET last_country = ? WHERE uuid = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, country == null ? "" : country); + statement.setString(2, uuid.toString()); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not update player country", exception); + } + } + + public Optional findByUuid(UUID uuid) { + String sql = "SELECT uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country " + + "FROM player_activity WHERE uuid = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return Optional.of(read(resultSet)); + } + } + return Optional.empty(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not read player activity", exception); + } + } + + public Map findAllByUuid() { + String sql = "SELECT uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country FROM player_activity"; + Map players = new HashMap<>(); + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + PlayerActivity entry = read(resultSet); + players.put(entry.uuid(), entry); + } + return players; + } catch (SQLException exception) { + throw new IllegalStateException("Could not list player activity", exception); + } + } + + private PlayerActivity read(ResultSet resultSet) throws SQLException { + return new PlayerActivity( + UUID.fromString(resultSet.getString("uuid")), + resultSet.getLong("first_joined"), + resultSet.getLong("last_seen"), + resultSet.getLong("total_playtime_seconds"), + resultSet.getLong("total_sessions"), + resultSet.getLong("current_session_start"), + resultSet.getString("last_ip"), + resultSet.getString("last_country") + ); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/persistence/UserRepository.java b/src/main/java/de/winniepat/minePanel/persistence/UserRepository.java new file mode 100644 index 0000000..96c0a0c --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/persistence/UserRepository.java @@ -0,0 +1,317 @@ +package de.winniepat.minePanel.persistence; + +import de.winniepat.minePanel.users.*; + +import java.sql.*; +import java.time.Instant; +import java.util.*; + +public final class UserRepository { + + private final Database database; + + public UserRepository(Database database) { + this.database = database; + } + + public long countUsers() { + String sql = "SELECT COUNT(*) AS amount FROM users"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + return resultSet.getLong("amount"); + } catch (SQLException exception) { + throw new IllegalStateException("Could not count users", exception); + } + } + + public long countOwners() { + String sql = "SELECT COUNT(*) AS amount FROM users WHERE role = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, UserRole.OWNER.name()); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.getLong("amount"); + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not count owners", exception); + } + } + + public int demoteExtraOwnersToAdmin() { + String selectSql = "SELECT id FROM users WHERE role = ? ORDER BY id ASC"; + String updateSql = "UPDATE users SET role = ? WHERE id = ?"; + + try (Connection connection = database.getConnection(); + PreparedStatement selectStatement = connection.prepareStatement(selectSql); + PreparedStatement updateStatement = connection.prepareStatement(updateSql)) { + connection.setAutoCommit(false); + selectStatement.setString(1, UserRole.OWNER.name()); + + List ownerIds = new ArrayList<>(); + try (ResultSet resultSet = selectStatement.executeQuery()) { + while (resultSet.next()) { + ownerIds.add(resultSet.getLong("id")); + } + } + + if (ownerIds.size() <= 1) { + connection.rollback(); + return 0; + } + + int updated = 0; + for (int i = 1; i < ownerIds.size(); i++) { + long userId = ownerIds.get(i); + updateStatement.setString(1, UserRole.ADMIN.name()); + updateStatement.setLong(2, userId); + if (updateStatement.executeUpdate() > 0) { + savePermissions(connection, userId, UserRole.ADMIN.defaultPermissions()); + updated++; + } + } + + connection.commit(); + return updated; + } catch (SQLException exception) { + throw new IllegalStateException("Could not normalize owner accounts", exception); + } + } + + public PanelUser createUser(String username, String passwordHash, UserRole role) { + return createUser(username, passwordHash, role, role.defaultPermissions()); + } + + public PanelUser createUser(String username, String passwordHash, UserRole role, Set permissions) { + String sql = "INSERT INTO users(username, password_hash, role, created_at) VALUES (?, ?, ?, ?)"; + long createdAt = Instant.now().toEpochMilli(); + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS)) { + connection.setAutoCommit(false); + statement.setString(1, username); + statement.setString(2, passwordHash); + statement.setString(3, role.name()); + statement.setLong(4, createdAt); + statement.executeUpdate(); + + long userId; + try (ResultSet keys = statement.getGeneratedKeys()) { + if (keys.next()) { + userId = keys.getLong(1); + } else { + throw new IllegalStateException("Could not create user (missing generated key)"); + } + } + + Set sanitized = sanitizePermissions(permissions, role); + savePermissions(connection, userId, sanitized); + connection.commit(); + return new PanelUser(userId, username, role, sanitized, createdAt); + } catch (SQLException exception) { + throw new IllegalStateException("Could not create user", exception); + } + } + + public Optional findByUsername(String username) { + String sql = "SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, username); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return Optional.empty(); + } + PanelUser user = mapUser(connection, resultSet); + String passwordHash = resultSet.getString("password_hash"); + return Optional.of(new PanelUserAuth(user, passwordHash)); + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not find user by username", exception); + } + } + + public Optional findById(long userId) { + String sql = "SELECT id, username, role, created_at FROM users WHERE id = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, userId); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return Optional.of(mapUser(connection, resultSet)); + } + return Optional.empty(); + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not find user by id", exception); + } + } + + public List findAllUsers() { + String sql = "SELECT id, username, role, created_at FROM users ORDER BY id ASC"; + String permissionSql = "SELECT user_id, permission FROM user_permissions ORDER BY user_id ASC"; + List users = new ArrayList<>(); + try (Connection connection = database.getConnection(); + PreparedStatement permissionStatement = connection.prepareStatement(permissionSql); + ResultSet permissionResultSet = permissionStatement.executeQuery(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + Map> permissionsByUser = new HashMap<>(); + while (permissionResultSet.next()) { + long userId = permissionResultSet.getLong("user_id"); + String rawPermission = permissionResultSet.getString("permission"); + PanelPermission permission; + try { + permission = PanelPermission.valueOf(rawPermission); + } catch (IllegalArgumentException ignored) { + continue; + } + permissionsByUser.computeIfAbsent(userId, ignored -> EnumSet.noneOf(PanelPermission.class)).add(permission); + } + + while (resultSet.next()) { + long userId = resultSet.getLong("id"); + UserRole role = UserRole.fromString(resultSet.getString("role")); + Set permissions = permissionsByUser.getOrDefault(userId, role.defaultPermissions()); + users.add(new PanelUser( + userId, + resultSet.getString("username"), + role, + EnumSet.copyOf(permissions), + resultSet.getLong("created_at") + )); + } + return users; + } catch (SQLException exception) { + throw new IllegalStateException("Could not list users", exception); + } + } + + public boolean updateRole(long userId, UserRole role) { + String sql = "UPDATE users SET role = ? WHERE id = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + connection.setAutoCommit(false); + statement.setString(1, role.name()); + statement.setLong(2, userId); + boolean changed = statement.executeUpdate() > 0; + if (!changed) { + connection.rollback(); + return false; + } + + savePermissions(connection, userId, role.defaultPermissions()); + connection.commit(); + return true; + } catch (SQLException exception) { + throw new IllegalStateException("Could not update user role", exception); + } + } + + public boolean updatePermissions(long userId, Set permissions) { + Optional target = findById(userId); + if (target.isEmpty()) { + return false; + } + + Set sanitized = sanitizePermissions(permissions, target.get().role()); + String sql = "SELECT id FROM users WHERE id = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + connection.setAutoCommit(false); + statement.setLong(1, userId); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + connection.rollback(); + return false; + } + } + + savePermissions(connection, userId, sanitized); + connection.commit(); + return true; + } catch (SQLException exception) { + throw new IllegalStateException("Could not update user permissions", exception); + } + } + + public boolean deleteUser(long userId) { + String sql = "DELETE FROM users WHERE id = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, userId); + return statement.executeUpdate() > 0; + } catch (SQLException exception) { + throw new IllegalStateException("Could not delete user", exception); + } + } + + private PanelUser mapUser(Connection connection, ResultSet resultSet) throws SQLException { + long userId = resultSet.getLong("id"); + UserRole role = UserRole.fromString(resultSet.getString("role")); + return new PanelUser( + userId, + resultSet.getString("username"), + role, + loadPermissions(connection, userId, role), + resultSet.getLong("created_at") + ); + } + + private Set loadPermissions(Connection connection, long userId, UserRole role) throws SQLException { + String sql = "SELECT permission FROM user_permissions WHERE user_id = ?"; + Set permissions = EnumSet.noneOf(PanelPermission.class); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, userId); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + String rawPermission = resultSet.getString("permission"); + try { + permissions.add(PanelPermission.valueOf(rawPermission)); + } catch (IllegalArgumentException ignored) { + // Ignore unknown permissions from older/newer versions. + } + } + } + } + + if (permissions.isEmpty()) { + return role.defaultPermissions(); + } + return permissions; + } + + private void savePermissions(Connection connection, long userId, Set permissions) throws SQLException { + try (PreparedStatement deleteStatement = connection.prepareStatement("DELETE FROM user_permissions WHERE user_id = ?")) { + deleteStatement.setLong(1, userId); + deleteStatement.executeUpdate(); + } + + String insertSql = "INSERT INTO user_permissions(user_id, permission) VALUES (?, ?)"; + try (PreparedStatement insertStatement = connection.prepareStatement(insertSql)) { + for (PanelPermission permission : permissions) { + insertStatement.setLong(1, userId); + insertStatement.setString(2, permission.name()); + insertStatement.addBatch(); + } + insertStatement.executeBatch(); + } + } + + private Set sanitizePermissions(Set permissions, UserRole role) { + if (role == UserRole.OWNER) { + return EnumSet.allOf(PanelPermission.class); + } + + Set source = permissions == null || permissions.isEmpty() ? role.defaultPermissions() : permissions; + Set sanitized = EnumSet.noneOf(PanelPermission.class); + for (PanelPermission permission : source) { + if (permission == null || !permission.assignable()) { + continue; + } + sanitized.add(permission); + } + sanitized.add(PanelPermission.ACCESS_PANEL); + return sanitized; + } +} + diff --git a/src/main/java/de/winniepat/minePanel/users/PanelPermission.java b/src/main/java/de/winniepat/minePanel/users/PanelPermission.java new file mode 100644 index 0000000..97850ba --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/users/PanelPermission.java @@ -0,0 +1,77 @@ +package de.winniepat.minePanel.users; + +public enum PanelPermission { + // Legacy keys kept for compatibility with older route guards. + VIEW_DASHBOARD("Legacy", "View Dashboard", null, false), + VIEW_LOGS("Legacy", "View Logs", null, false), + + ACCESS_PANEL("Panel", "Access Panel", null, true), + + VIEW_OVERVIEW("Server", "View Overview", null, true), + VIEW_CONSOLE("Server", "View Console", null, true), + SEND_CONSOLE("Server", "Send Console Commands", null, true), + VIEW_RESOURCES("Server", "View Resources", null, true), + VIEW_PLAYERS("Server", "View Players", null, true), + MANAGE_PLAYERS("Server", "Manage Players", null, true), + VIEW_BANS("Server", "View Bans", null, true), + MANAGE_BANS("Server", "Manage Bans", null, true), + VIEW_PLUGINS("Server", "View Plugins", null, true), + MANAGE_PLUGINS("Server", "Install Plugins", null, true), + + VIEW_USERS("Panel", "View Users", null, true), + MANAGE_USERS("Panel", "Manage Users", null, true), + VIEW_DISCORD_WEBHOOK("Panel", "View Discord Webhook", null, true), + MANAGE_DISCORD_WEBHOOK("Panel", "Manage Discord Webhook", null, true), + VIEW_THEMES("Panel", "View Themes", null, true), + MANAGE_THEMES("Panel", "Manage Themes", null, true), + VIEW_EXTENSIONS("Panel", "View Extensions", null, true), + MANAGE_EXTENSIONS("Panel", "Manage Extensions", null, true), + + VIEW_BACKUPS("Extensions", "View Backups", "world-backups", true), + MANAGE_BACKUPS("Extensions", "Manage Backups", "world-backups", true), + VIEW_REPORTS("Extensions", "View Reports", "report-system", true), + MANAGE_REPORTS("Extensions", "Manage Reports", "report-system", true), + VIEW_TICKETS("Extensions", "View Tickets", "ticket-system", true), + MANAGE_TICKETS("Extensions", "Manage Tickets", "ticket-system", true), + VIEW_PLAYER_MANAGEMENT("Extensions", "View Player Management", "player-management", true), + MANAGE_PLAYER_MANAGEMENT("Extensions", "Manage Player Management", "player-management", true), + VIEW_PLAYER_STATS("Extensions", "View Player Stats", "player-stats", true), + VIEW_LUCKPERMS("Extensions", "View LuckPerms Data", "luckperms", true), + VIEW_AIRSTRIKE("Extensions", "View Airstrike", "airstrike", true), + MANAGE_AIRSTRIKE("Extensions", "Launch Airstrike", "airstrike", true), + VIEW_MAINTENANCE("Extensions", "View Maintenance", "maintenance", true), + MANAGE_MAINTENANCE("Extensions", "Manage Maintenance", "maintenance", true), + VIEW_WHITELIST("Extensions", "View Whitelist", "whitelist", true), + MANAGE_WHITELIST("Extensions", "Manage Whitelist", "whitelist", true), + VIEW_ANNOUNCEMENTS("Extensions", "View Announcements", "announcements", true), + MANAGE_ANNOUNCEMENTS("Extensions", "Manage Announcements", "announcements", true); + + private final String category; + private final String label; + private final String extensionId; + private final boolean assignable; + + PanelPermission(String category, String label, String extensionId, boolean assignable) { + this.category = category; + this.label = label; + this.extensionId = extensionId; + this.assignable = assignable; + } + + public String category() { + return category; + } + + public String label() { + return label; + } + + public String extensionId() { + return extensionId; + } + + public boolean assignable() { + return assignable; + } +} + diff --git a/src/main/java/de/winniepat/minePanel/users/PanelUser.java b/src/main/java/de/winniepat/minePanel/users/PanelUser.java new file mode 100644 index 0000000..9ba1ea6 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/users/PanelUser.java @@ -0,0 +1,41 @@ +package de.winniepat.minePanel.users; + +import java.util.Set; + +public record PanelUser( + long id, + String username, + UserRole role, + Set permissions, + long createdAt +) { + public boolean hasPermission(PanelPermission permission) { + if (role == UserRole.OWNER || permissions.contains(permission)) { + return true; + } + + if (permission != null) { + String name = permission.name(); + if (name.startsWith("VIEW_")) { + String manageName = "MANAGE_" + name.substring("VIEW_".length()); + try { + PanelPermission managePermission = PanelPermission.valueOf(manageName); + if (permissions.contains(managePermission)) { + return true; + } + } catch (IllegalArgumentException ignored) { + // ignored + } + } + } + + if (permission == PanelPermission.VIEW_DASHBOARD) { + return permissions.contains(PanelPermission.ACCESS_PANEL); + } + if (permission == PanelPermission.VIEW_LOGS) { + return permissions.contains(PanelPermission.VIEW_CONSOLE); + } + return false; + } +} + diff --git a/src/main/java/de/winniepat/minePanel/users/PanelUserAuth.java b/src/main/java/de/winniepat/minePanel/users/PanelUserAuth.java new file mode 100644 index 0000000..5fc9808 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/users/PanelUserAuth.java @@ -0,0 +1,8 @@ +package de.winniepat.minePanel.users; + +public record PanelUserAuth( + PanelUser user, + String passwordHash +) { +} + diff --git a/src/main/java/de/winniepat/minePanel/users/UserRole.java b/src/main/java/de/winniepat/minePanel/users/UserRole.java new file mode 100644 index 0000000..aae3ccc --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/users/UserRole.java @@ -0,0 +1,82 @@ +package de.winniepat.minePanel.users; + +import java.util.*; + +public enum UserRole { + OWNER(EnumSet.allOf(PanelPermission.class)), + ADMIN(EnumSet.of( + PanelPermission.ACCESS_PANEL, + PanelPermission.VIEW_OVERVIEW, + PanelPermission.VIEW_CONSOLE, + PanelPermission.SEND_CONSOLE, + PanelPermission.VIEW_RESOURCES, + PanelPermission.VIEW_PLAYERS, + PanelPermission.MANAGE_PLAYERS, + PanelPermission.VIEW_BANS, + PanelPermission.MANAGE_BANS, + PanelPermission.VIEW_PLUGINS, + PanelPermission.MANAGE_PLUGINS, + PanelPermission.VIEW_USERS, + PanelPermission.VIEW_DISCORD_WEBHOOK, + PanelPermission.MANAGE_DISCORD_WEBHOOK, + PanelPermission.VIEW_THEMES, + PanelPermission.VIEW_EXTENSIONS, + PanelPermission.VIEW_BACKUPS, + PanelPermission.MANAGE_BACKUPS, + PanelPermission.VIEW_REPORTS, + PanelPermission.MANAGE_REPORTS, + PanelPermission.VIEW_TICKETS, + PanelPermission.MANAGE_TICKETS, + PanelPermission.VIEW_PLAYER_MANAGEMENT, + PanelPermission.MANAGE_PLAYER_MANAGEMENT, + PanelPermission.VIEW_PLAYER_STATS, + PanelPermission.VIEW_LUCKPERMS, + PanelPermission.VIEW_AIRSTRIKE, + PanelPermission.MANAGE_AIRSTRIKE, + PanelPermission.VIEW_MAINTENANCE, + PanelPermission.MANAGE_MAINTENANCE, + PanelPermission.VIEW_WHITELIST, + PanelPermission.MANAGE_WHITELIST, + PanelPermission.VIEW_ANNOUNCEMENTS, + PanelPermission.MANAGE_ANNOUNCEMENTS + )), + VIEWER(EnumSet.of( + PanelPermission.ACCESS_PANEL, + PanelPermission.VIEW_OVERVIEW, + PanelPermission.VIEW_CONSOLE, + PanelPermission.VIEW_RESOURCES, + PanelPermission.VIEW_PLAYERS, + PanelPermission.VIEW_BANS, + PanelPermission.VIEW_PLUGINS, + PanelPermission.VIEW_THEMES, + PanelPermission.VIEW_BACKUPS, + PanelPermission.VIEW_REPORTS, + PanelPermission.VIEW_TICKETS, + PanelPermission.VIEW_PLAYER_MANAGEMENT, + PanelPermission.VIEW_PLAYER_STATS, + PanelPermission.VIEW_LUCKPERMS, + PanelPermission.VIEW_AIRSTRIKE, + PanelPermission.VIEW_MAINTENANCE, + PanelPermission.VIEW_WHITELIST, + PanelPermission.VIEW_ANNOUNCEMENTS + )); + + private final Set permissions; + + UserRole(Set permissions) { + this.permissions = permissions; + } + + public boolean hasPermission(PanelPermission permission) { + return permissions.contains(permission); + } + + public Set defaultPermissions() { + return EnumSet.copyOf(permissions); + } + + public static UserRole fromString(String raw) { + return UserRole.valueOf(raw.toUpperCase(Locale.ROOT)); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/util/ServerSchedulerBridge.java b/src/main/java/de/winniepat/minePanel/util/ServerSchedulerBridge.java new file mode 100644 index 0000000..672da83 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/util/ServerSchedulerBridge.java @@ -0,0 +1,184 @@ +package de.winniepat.minePanel.util; + +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +import java.lang.reflect.Method; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +public final class ServerSchedulerBridge { + + private final JavaPlugin plugin; + private final boolean folia; + private final Object globalRegionScheduler; + private final Method globalRunMethod; + private final Method globalRunDelayedMethod; + private final Method globalRunAtFixedRateMethod; + private final Object asyncScheduler; + private final Method asyncRunNowMethod; + + public ServerSchedulerBridge(JavaPlugin plugin) { + this.plugin = plugin; + + Object resolvedGlobalScheduler = null; + Method resolvedGlobalRun = null; + Method resolvedGlobalRunDelayed = null; + Method resolvedGlobalRunAtFixedRate = null; + + Object resolvedAsyncScheduler = null; + Method resolvedAsyncRunNow = null; + + try { + resolvedGlobalScheduler = plugin.getServer().getClass().getMethod("getGlobalRegionScheduler").invoke(plugin.getServer()); + resolvedGlobalRun = resolvedGlobalScheduler.getClass().getMethod("run", Plugin.class, Consumer.class); + resolvedGlobalRunDelayed = resolvedGlobalScheduler.getClass().getMethod("runDelayed", Plugin.class, Consumer.class, long.class); + resolvedGlobalRunAtFixedRate = resolvedGlobalScheduler.getClass().getMethod("runAtFixedRate", Plugin.class, Consumer.class, long.class, long.class); + + resolvedAsyncScheduler = plugin.getServer().getClass().getMethod("getAsyncScheduler").invoke(plugin.getServer()); + resolvedAsyncRunNow = resolvedAsyncScheduler.getClass().getMethod("runNow", Plugin.class, Consumer.class); + } catch (ReflectiveOperationException ignored) { + // Paper/Spigot fallback uses BukkitScheduler APIs. + } + + this.globalRegionScheduler = resolvedGlobalScheduler; + this.globalRunMethod = resolvedGlobalRun; + this.globalRunDelayedMethod = resolvedGlobalRunDelayed; + this.globalRunAtFixedRateMethod = resolvedGlobalRunAtFixedRate; + this.asyncScheduler = resolvedAsyncScheduler; + this.asyncRunNowMethod = resolvedAsyncRunNow; + this.folia = this.globalRegionScheduler != null && this.globalRunMethod != null; + } + + public boolean isFolia() { + return folia; + } + + public CancellableTask runRepeatingGlobal(Runnable task, long initialDelayTicks, long periodTicks) { + if (!folia) { + BukkitTask bukkitTask = plugin.getServer().getScheduler().runTaskTimer(plugin, task, initialDelayTicks, periodTicks); + return bukkitTask::cancel; + } + + if (globalRunDelayedMethod == null) { + try { + Object scheduledTask = globalRunAtFixedRateMethod.invoke( + globalRegionScheduler, + plugin, + (Consumer) ignored -> task.run(), + initialDelayTicks, + periodTicks + ); + return () -> cancelFoliaTask(scheduledTask); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("could_not_schedule_global_repeating_task", exception); + } + } + + long safeInitialDelay = Math.max(0L, initialDelayTicks); + long safePeriod = Math.max(1L, periodTicks); + AtomicBoolean cancelled = new AtomicBoolean(false); + AtomicReference scheduledTaskRef = new AtomicReference<>(); + + class FoliaRepeatingState { + void scheduleNext(long delayTicks) { + if (cancelled.get()) { + return; + } + + try { + Object scheduledTask = globalRunDelayedMethod.invoke( + globalRegionScheduler, + plugin, + (Consumer) ignored -> { + if (cancelled.get()) { + return; + } + + task.run(); + scheduleNext(safePeriod); + }, + Math.max(0L, delayTicks) + ); + scheduledTaskRef.set(scheduledTask); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("could_not_schedule_global_repeating_task", exception); + } + } + } + + FoliaRepeatingState repeatingState = new FoliaRepeatingState(); + // Do not block startup waiting for first tick; on Folia this can deadlock when called during enable. + repeatingState.scheduleNext(safeInitialDelay); + + return () -> { + cancelled.set(true); + cancelFoliaTask(scheduledTaskRef.get()); + }; + } + + public void runGlobal(Runnable task) { + if (!folia) { + plugin.getServer().getScheduler().runTask(plugin, task); + return; + } + + try { + globalRunMethod.invoke(globalRegionScheduler, plugin, (Consumer) ignored -> task.run()); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("could_not_schedule_global_task", exception); + } + } + + public void runAsync(Runnable task) { + if (!folia || asyncScheduler == null || asyncRunNowMethod == null) { + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, task); + return; + } + + try { + asyncRunNowMethod.invoke(asyncScheduler, plugin, (Consumer) ignored -> task.run()); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("could_not_schedule_async_task", exception); + } + } + + public T callGlobal(Callable task, long timeout, TimeUnit timeUnit) + throws InterruptedException, ExecutionException, TimeoutException { + CompletableFuture future = new CompletableFuture<>(); + runGlobal(() -> { + try { + future.complete(task.call()); + } catch (Throwable throwable) { + future.completeExceptionally(throwable); + } + }); + return future.get(timeout, timeUnit); + } + + private void cancelFoliaTask(Object task) { + if (task == null) { + return; + } + + try { + Method cancelMethod = task.getClass().getMethod("cancel"); + cancelMethod.invoke(task); + } catch (ReflectiveOperationException ignored) { + // Best-effort cancellation. + } + } + + @FunctionalInterface + public interface CancellableTask { + void cancel(); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/web/BootstrapService.java b/src/main/java/de/winniepat/minePanel/web/BootstrapService.java new file mode 100644 index 0000000..9c1aa1c --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/web/BootstrapService.java @@ -0,0 +1,41 @@ +package de.winniepat.minePanel.web; + +import de.winniepat.minePanel.persistence.UserRepository; + +import java.security.SecureRandom; +import java.util.*; + +public final class BootstrapService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final UserRepository userRepository; + private final String bootstrapToken; + + public BootstrapService(UserRepository userRepository, int tokenLength) { + this.userRepository = userRepository; + this.bootstrapToken = generateToken(Math.max(16, tokenLength)); + } + + public boolean needsBootstrap() { + return userRepository.countUsers() == 0; + } + + public Optional getBootstrapToken() { + if (!needsBootstrap()) { + return Optional.empty(); + } + return Optional.of(bootstrapToken); + } + + public boolean verifyToken(String token) { + return needsBootstrap() && token != null && bootstrapToken.equals(token); + } + + private String generateToken(int length) { + byte[] bytes = new byte[length]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).substring(0, length); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/web/ResourceLoader.java b/src/main/java/de/winniepat/minePanel/web/ResourceLoader.java new file mode 100644 index 0000000..f78b427 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/web/ResourceLoader.java @@ -0,0 +1,22 @@ +package de.winniepat.minePanel.web; + +import java.io.*; +import java.nio.charset.StandardCharsets; + +public final class ResourceLoader { + + private ResourceLoader() { + } + + public static String loadUtf8Text(String resourcePath) { + try (InputStream inputStream = ResourceLoader.class.getResourceAsStream(resourcePath)) { + if (inputStream == null) { + throw new IllegalStateException("Resource not found: " + resourcePath); + } + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("Could not read resource: " + resourcePath, exception); + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/web/WebAssetService.java b/src/main/java/de/winniepat/minePanel/web/WebAssetService.java new file mode 100644 index 0000000..9bd2f8c --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/web/WebAssetService.java @@ -0,0 +1,166 @@ +package de.winniepat.minePanel.web; + +import de.winniepat.minePanel.MinePanel; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +public final class WebAssetService { + + private static final List BUNDLED_WEB_FILES = List.of( + "dashboard-account.html", + "dashboard-announcements.html", + "dashboard-bans.html", + "dashboard-console.html", + "dashboard-discord-webhook.html", + "dashboard-extension-config.html", + "dashboard-extensions.html", + "dashboard-maintenance.html", + "dashboard-overview.html", + "dashboard-players.html", + "dashboard-plugins.html", + "dashboard-reports.html", + "dashboard-resources.html", + "dashboard-tickets.html", + "dashboard-world-backups.html", + "dashboard-whitelist.html", + "dashboard-themes.html", + "dashboard-users.html", + "login.html", + "panel.css", + "setup.html", + "theme.js" + ); + + private static final String LEGACY_EXTENSIONS_DOWNLOAD_MARKER = "href=\"${downloadUrl}\""; + private static final String CURRENT_EXTENSIONS_INSTALL_MARKER = "data-install-extension"; + private static final long VERSION_CACHE_TTL_MILLIS = TimeUnit.SECONDS.toMillis(2); + + private final MinePanel plugin; + private final Path webDirectory; + private final Map cachedTextAssets = new ConcurrentHashMap<>(); + private volatile long cachedVersion = 0L; + private volatile long cachedVersionExpiresAt = 0L; + + public WebAssetService(MinePanel plugin, Path webDirectory) { + this.plugin = plugin; + this.webDirectory = webDirectory; + } + + public void ensureSeeded() { + try { + Files.createDirectories(webDirectory); + } catch (IOException exception) { + throw new IllegalStateException("Could not create web directory: " + webDirectory, exception); + } + + for (String fileName : BUNDLED_WEB_FILES) { + Path target = webDirectory.resolve(fileName).normalize(); + if (!target.startsWith(webDirectory)) { + continue; + } + if (Files.exists(target)) { + migrateLegacyAssets(fileName, target); + continue; + } + plugin.saveResource("web/" + fileName, false); + } + } + + private void migrateLegacyAssets(String fileName, Path target) { + if (!"dashboard-extensions.html".equals(fileName)) { + return; + } + + try { + String current = Files.readString(target, StandardCharsets.UTF_8); + if (current.contains(CURRENT_EXTENSIONS_INSTALL_MARKER)) { + return; + } + if (!current.contains(LEGACY_EXTENSIONS_DOWNLOAD_MARKER)) { + return; + } + + String bundled = ResourceLoader.loadUtf8Text("/web/" + fileName); + Files.writeString(target, bundled, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + plugin.getLogger().info("Updated legacy web template: " + fileName); + } catch (Exception exception) { + plugin.getLogger().warning("Could not migrate legacy web template " + fileName + ": " + exception.getMessage()); + } + } + + public String readText(String fileName) { + Path target = resolveFile(fileName); + try { + long lastModified = Files.exists(target) ? Files.getLastModifiedTime(target).toMillis() : 0L; + CachedTextAsset cached = cachedTextAssets.get(fileName); + if (cached != null && cached.lastModifiedMillis() == lastModified) { + return cached.content(); + } + + String content = Files.readString(target, StandardCharsets.UTF_8); + cachedTextAssets.put(fileName, new CachedTextAsset(lastModified, content)); + return content; + } catch (IOException exception) { + throw new IllegalStateException("Could not read web asset: " + fileName, exception); + } + } + + public long currentVersion() { + long now = System.currentTimeMillis(); + if (now < cachedVersionExpiresAt) { + return cachedVersion; + } + + long newest = 0L; + for (String fileName : BUNDLED_WEB_FILES) { + Path target = resolveFile(fileName); + if (!Files.exists(target)) { + continue; + } + try { + newest = Math.max(newest, Files.getLastModifiedTime(target).toMillis()); + } catch (IOException ignored) { + // Ignore broken files so the rest of the panel can continue serving assets. + } + } + cachedVersion = newest; + cachedVersionExpiresAt = now + VERSION_CACHE_TTL_MILLIS; + return newest; + } + + public long lastModifiedMillis(String fileName) { + Path target = resolveFile(fileName); + try { + if (!Files.exists(target)) { + return 0L; + } + return Files.getLastModifiedTime(target).toMillis(); + } catch (IOException exception) { + return 0L; + } + } + + private Path resolveFile(String fileName) { + Path target = webDirectory.resolve(fileName).normalize(); + if (!target.startsWith(webDirectory)) { + throw new IllegalArgumentException("Invalid web asset path: " + fileName); + } + return target; + } + + public Path webDirectory() { + return webDirectory; + } + + private record CachedTextAsset(long lastModifiedMillis, String content) { + } +} + diff --git a/src/main/java/de/winniepat/minePanel/web/WebPanelServer.java b/src/main/java/de/winniepat/minePanel/web/WebPanelServer.java new file mode 100644 index 0000000..174aee7 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/web/WebPanelServer.java @@ -0,0 +1,3402 @@ +package de.winniepat.minePanel.web; + +import com.google.gson.*; +import de.winniepat.minePanel.MinePanel; +import de.winniepat.minePanel.auth.*; +import de.winniepat.minePanel.config.WebPanelConfig; +import de.winniepat.minePanel.extensions.*; +import de.winniepat.minePanel.integrations.*; +import de.winniepat.minePanel.logs.*; +import de.winniepat.minePanel.persistence.*; +import de.winniepat.minePanel.util.ServerSchedulerBridge; +import org.bukkit.BanEntry; +import de.winniepat.minePanel.users.*; +import org.bukkit.BanList; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import spark.*; + +import java.io.IOException; +import java.net.*; +import java.net.http.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.SecureRandom; +import java.lang.management.BufferPoolMXBean; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static spark.Spark.after; +import static spark.Spark.awaitInitialization; +import static spark.Spark.awaitStop; +import static spark.Spark.exception; +import static spark.Spark.get; +import static spark.Spark.halt; +import static spark.Spark.ipAddress; +import static spark.Spark.notFound; +import static spark.Spark.path; +import static spark.Spark.port; +import static spark.Spark.post; + +public final class WebPanelServer { + + private static final String SESSION_COOKIE = "PANEL_SESSION"; + private static final List PAPER_PLUGIN_LOADERS = List.of("paper", "spigot", "bukkit", "purpur", "folia"); + private static final long OVERVIEW_WINDOW_MILLIS = 60L * 60L * 1000L; + private static final long OVERVIEW_SAMPLE_INTERVAL_TICKS = 20L * 5L; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final String OAUTH_MODE_LOGIN = "login"; + private static final String OAUTH_MODE_LINK = "link"; + private final long serverStartedAtMillis = System.currentTimeMillis(); + + private final MinePanel plugin; + private final WebPanelConfig config; + private final UserRepository userRepository; + private final SessionService sessionService; + private final PasswordHasher passwordHasher; + private final LogRepository logRepository; + private final KnownPlayerRepository knownPlayerRepository; + private final PlayerActivityRepository playerActivityRepository; + private final JoinLeaveEventRepository joinLeaveEventRepository; + private final DiscordWebhookService discordWebhookService; + private final PanelLogger panelLogger; + private final ServerLogService serverLogService; + private final BootstrapService bootstrapService; + private final OAuthAccountRepository oAuthAccountRepository; + private final OAuthStateRepository oAuthStateRepository; + private final ExtensionManager extensionManager; + private final WebAssetService webAssetService; + private final ExtensionSettingsRepository extensionSettingsRepository; + private final Gson gson = new Gson(); + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final Deque overviewSamples = new ArrayDeque<>(); + private JsonArray cachedGitHubReleases = new JsonArray(); + private long githubReleaseCacheExpiresAtMillis = 0L; + private String lastGitHubCatalogError = ""; + private final Set restartRequiredExtensionIds = new HashSet<>(); + private ServerSchedulerBridge.CancellableTask overviewSamplerTask; + + public WebPanelServer( + MinePanel plugin, + WebPanelConfig config, + UserRepository userRepository, + SessionService sessionService, + PasswordHasher passwordHasher, + LogRepository logRepository, + KnownPlayerRepository knownPlayerRepository, + PlayerActivityRepository playerActivityRepository, + DiscordWebhookService discordWebhookService, + PanelLogger panelLogger, + ServerLogService serverLogService, + BootstrapService bootstrapService, + JoinLeaveEventRepository joinLeaveEventRepository, + OAuthAccountRepository oAuthAccountRepository, + OAuthStateRepository oAuthStateRepository, + ExtensionManager extensionManager, + WebAssetService webAssetService, + ExtensionSettingsRepository extensionSettingsRepository + ) { + this.plugin = plugin; + this.config = config; + this.userRepository = userRepository; + this.sessionService = sessionService; + this.passwordHasher = passwordHasher; + this.logRepository = logRepository; + this.knownPlayerRepository = knownPlayerRepository; + this.playerActivityRepository = playerActivityRepository; + this.joinLeaveEventRepository = joinLeaveEventRepository; + this.discordWebhookService = discordWebhookService; + this.panelLogger = panelLogger; + this.serverLogService = serverLogService; + this.bootstrapService = bootstrapService; + this.oAuthAccountRepository = oAuthAccountRepository; + this.oAuthStateRepository = oAuthStateRepository; + this.extensionManager = extensionManager; + this.webAssetService = webAssetService; + this.extensionSettingsRepository = extensionSettingsRepository; + } + + public void start() { + ipAddress(config.host()); + port(config.port()); + + exception(Exception.class, (exception, request, response) -> { + plugin.getLogger().warning("Web panel error: " + exception.getMessage()); + response.status(500); + response.type("application/json"); + response.body(gson.toJson(Map.of("error", "internal_server_error"))); + }); + + after((request, response) -> { + String path = request.pathInfo(); + if (path == null) { + return; + } + + if (path.startsWith("/api")) { + response.header("Cache-Control", "no-store"); + return; + } + + if (path.equals("/") + || path.equals("/setup") + || path.equals("/console") + || path.startsWith("/dashboard")) { + response.header("Cache-Control", "no-store"); + } + }); + + notFound((request, response) -> { + response.type("application/json"); + return gson.toJson(Map.of("error", "not_found")); + }); + + get("/", (request, response) -> { + response.type("text/html"); + if (bootstrapService.needsBootstrap()) { + return webAssetService.readText("setup.html"); + } + return webAssetService.readText("login.html"); + }); + + get("/setup", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("setup.html"); + }); + + get("/dashboard", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-overview.html"); + }); + + get("/console", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-console.html"); + }); + + get("/dashboard/console", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-console.html"); + }); + + get("/dashboard/users", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-users.html"); + }); + + get("/dashboard/plugins", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-plugins.html"); + }); + + get("/dashboard/overview", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-overview.html"); + }); + + get("/dashboard/bans", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-bans.html"); + }); + + + get("/dashboard/resources", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-resources.html"); + }); + + get("/dashboard/health", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-resources.html"); + }); + + get("/dashboard/players", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-players.html"); + }); + + get("/dashboard/discord-webhook", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-discord-webhook.html"); + }); + + get("/dashboard/themes", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-themes.html"); + }); + + get("/dashboard/extensions", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-extensions.html"); + }); + + get("/dashboard/extension-config", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-extension-config.html"); + }); + + get("/dashboard/account", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-account.html"); + }); + + get("/dashboard/reports", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-reports.html"); + }); + + get("/dashboard/tickets", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-tickets.html"); + }); + + get("/dashboard/world-backups", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-world-backups.html"); + }); + + get("/dashboard/maintenance", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-maintenance.html"); + }); + + get("/dashboard/whitelist", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-whitelist.html"); + }); + + get("/dashboard/announcements", (request, response) -> { + response.type("text/html"); + return webAssetService.readText("dashboard-announcements.html"); + }); + + get("/panel.css", (request, response) -> { + return serveStaticWebAsset(request, response, "panel.css", "text/css"); + }); + + get("/theme.js", (request, response) -> { + return serveStaticWebAsset(request, response, "theme.js", "application/javascript"); + }); + + get("/.well-known/appspecific/com.chrome.devtools.json", (request, response) -> { + response.status(204); + return ""; + }); + + path("/api", () -> { + post("/bootstrap", (request, response) -> handleBootstrap(request, response)); + post("/login", (request, response) -> handleLogin(request, response)); + post("/logout", (request, response) -> handleLogout(request, response)); + get("/me", (request, response) -> handleMe(request, response)); + get("/oauth/providers", (request, response) -> handleOAuthProviders(response)); + post("/oauth/:provider/start", (request, response) -> handleOAuthStart(request, response)); + get("/oauth/:provider/callback", (request, response) -> handleOAuthCallback(request, response)); + post("/oauth/:provider/unlink", (request, response) -> handleOAuthUnlink(request, response)); + get("/account/links", (request, response) -> handleAccountLinks(request, response)); + get("/extensions/navigation", (request, response) -> handleExtensionNavigation(request, response)); + get("/extensions/status", (request, response) -> handleExtensionStatus(request, response)); + get("/extensions/config", (request, response) -> handleExtensionConfigList(request, response)); + get("/extensions/config/:id", (request, response) -> handleExtensionConfigGet(request, response)); + post("/extensions/config/:id", (request, response) -> handleExtensionConfigSave(request, response)); + post("/extensions/install", (request, response) -> handleExtensionInstall(request, response)); + post("/extensions/reload", (request, response) -> handleExtensionReload(request, response)); + get("/web/live-version", (request, response) -> handleWebLiveVersion(response)); + get("/users", (request, response) -> handleListUsers(request, response)); + post("/users", (request, response) -> handleCreateUser(request, response)); + post("/users/:id/role", (request, response) -> handleUpdateRole(request, response)); + post("/users/:id/permissions", (request, response) -> handleUpdatePermissions(request, response)); + post("/users/:id/delete", (request, response) -> handleDeleteUser(request, response)); + get("/logs", (request, response) -> handleLogs(request, response)); + get("/logs/latest", (request, response) -> handleLatestLogId(request, response)); + get("/players", (request, response) -> handlePlayers(request, response)); + get("/players/profile/:uuid", (request, response) -> handlePlayerProfile(request, response)); + get("/plugins", (request, response) -> handlePlugins(request, response)); + get("/uptime", (request, response) -> handleUptime(request, response)); + get("/health", (request, response) -> handleHealth(request, response)); + get("/overview/metrics", (request, response) -> handleOverviewMetrics(request, response)); + get("/plugin-marketplace/search", (request, response) -> handlePluginMarketplaceSearch(request, response)); + get("/plugin-marketplace/versions", (request, response) -> handlePluginMarketplaceVersions(request, response)); + post("/plugin-marketplace/install", (request, response) -> handlePluginMarketplaceInstall(request, response)); + post("/players/:uuid/kick", (request, response) -> handleKickPlayer(request, response)); + post("/players/:uuid/temp-ban", (request, response) -> handleTempBanPlayer(request, response)); + post("/players/ban", (request, response) -> handleBanPlayer(request, response)); + post("/players/unban", (request, response) -> handleUnbanPlayer(request, response)); + post("/console/send", (request, response) -> handleSendConsole(request, response)); + get("/integrations/discord-webhook", (request, response) -> handleGetDiscordWebhook(request, response)); + post("/integrations/discord-webhook", (request, response) -> handleSaveDiscordWebhook(request, response)); + }); + + extensionManager.registerWebRoutes(new ExtensionSparkRegistry()); + + startOverviewSampler(); + awaitInitialization(); + } + + public void stop() { + stopOverviewSampler(); + spark.Spark.stop(); + awaitStop(); + } + + private String handleWebLiveVersion(Response response) { + response.type("application/json"); + return gson.toJson(Map.of("version", webAssetService.currentVersion())); + } + + private void startOverviewSampler() { + if (overviewSamplerTask != null) { + overviewSamplerTask.cancel(); + } + + // Capture one sample immediately, then continue in a fixed cadence. + synchronized (overviewSamples) { + captureOverviewSample(); + } + overviewSamplerTask = plugin.schedulerBridge().runRepeatingGlobal(() -> { + synchronized (overviewSamples) { + captureOverviewSample(); + } + }, OVERVIEW_SAMPLE_INTERVAL_TICKS, OVERVIEW_SAMPLE_INTERVAL_TICKS); + } + + private void stopOverviewSampler() { + if (overviewSamplerTask != null) { + overviewSamplerTask.cancel(); + overviewSamplerTask = null; + } + } + + private void captureOverviewSample() { + long now = System.currentTimeMillis(); + Runtime runtime = Runtime.getRuntime(); + long committedHeapBytes = runtime.totalMemory(); + long maxMemoryBytes = runtime.maxMemory(); + long nonHeapUsedBytes = readNonHeapUsedBytes(); + long nativeBufferBytes = readBufferPoolUsedBytes("direct") + readBufferPoolUsedBytes("mapped"); + + long effectiveUsedMemoryBytes = committedHeapBytes + nonHeapUsedBytes + nativeBufferBytes; + + double memoryUsedMb = effectiveUsedMemoryBytes / (1024.0 * 1024.0); + double memoryPercent = maxMemoryBytes > 0 ? (effectiveUsedMemoryBytes * 100.0 / maxMemoryBytes) : 0.0; + memoryPercent = Math.max(0.0, Math.min(100.0, memoryPercent)); + double cpuPercent = readProcessCpuPercent(); + double tps = readPrimaryTps(); + + overviewSamples.addLast(new MetricSample(now, tps, memoryPercent, memoryUsedMb, cpuPercent)); + + long cutoff = now - OVERVIEW_WINDOW_MILLIS; + while (!overviewSamples.isEmpty() && overviewSamples.peekFirst().timestampMillis() < cutoff) { + overviewSamples.removeFirst(); + } + } + + private double readPrimaryTps() { + try { + Object result = plugin.getServer().getClass().getMethod("getTPS").invoke(plugin.getServer()); + if (result instanceof double[] values && values.length > 0) { + return values[0]; + } + } catch (Exception ignored) { + // On unsupported server implementations, TPS remains unavailable. + } + return -1.0; + } + + private double readProcessCpuPercent() { + java.lang.management.OperatingSystemMXBean mxBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean(); + if (mxBean instanceof com.sun.management.OperatingSystemMXBean sunBean) { + double load = sunBean.getProcessCpuLoad(); + if (load >= 0) { + return load * 100.0; + } + } + return -1.0; + } + + private long readNonHeapUsedBytes() { + try { + MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean(); + if (memoryMXBean == null || memoryMXBean.getNonHeapMemoryUsage() == null) { + return 0L; + } + return Math.max(0L, memoryMXBean.getNonHeapMemoryUsage().getUsed()); + } catch (Exception ignored) { + return 0L; + } + } + + private long readBufferPoolUsedBytes(String poolName) { + try { + for (BufferPoolMXBean bufferPool : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) { + if (bufferPool != null && poolName.equalsIgnoreCase(bufferPool.getName())) { + return Math.max(0L, bufferPool.getMemoryUsed()); + } + } + } catch (Exception ignored) { + // Optional JVM metric; treat as unavailable. + } + return 0L; + } + + private String handleOverviewMetrics(Request request, Response response) { + requireUser(request, PanelPermission.VIEW_OVERVIEW); + + List> tpsHistory = new ArrayList<>(); + List> memoryHistory = new ArrayList<>(); + List> cpuHistory = new ArrayList<>(); + MetricSample latest; + + synchronized (overviewSamples) { + if (overviewSamples.isEmpty()) { + captureOverviewSample(); + } + + latest = overviewSamples.peekLast(); + for (MetricSample sample : overviewSamples) { + tpsHistory.add(Map.of("timestamp", sample.timestampMillis(), "value", sample.tps())); + memoryHistory.add(Map.of("timestamp", sample.timestampMillis(), "value", sample.memoryPercent())); + cpuHistory.add(Map.of("timestamp", sample.timestampMillis(), "value", sample.cpuPercent())); + } + } + + Map current = new HashMap<>(); + double memoryMaxMb = Runtime.getRuntime().maxMemory() / (1024.0 * 1024.0); + current.put("tps", latest == null ? -1.0 : latest.tps()); + current.put("memoryPercent", latest == null ? 0.0 : latest.memoryPercent()); + current.put("memoryUsedMb", latest == null ? 0.0 : latest.memoryUsedMb()); + current.put("memoryMaxMb", Math.max(0.0, memoryMaxMb)); + current.put("cpuPercent", latest == null ? -1.0 : latest.cpuPercent()); + + Map history = new HashMap<>(); + history.put("tps", tpsHistory); + history.put("memory", memoryHistory); + history.put("cpu", cpuHistory); + + long heatmapWindowDays = 14L; + long since = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(heatmapWindowDays); + JoinLeaveHeatmap heatmap = buildJoinLeaveHeatmaps(joinLeaveEventRepository.listEventsSince(since)); + + return json(response, 200, Map.of( + "current", current, + "history", history, + "windowMinutes", 60, + "joinHeatmap", heatmap.joinCells(), + "leaveHeatmap", heatmap.leaveCells(), + "heatmapMaxJoin", heatmap.maxJoin(), + "heatmapMaxLeave", heatmap.maxLeave(), + "heatmapWindowDays", heatmapWindowDays + )); + } + + private JoinLeaveHeatmap buildJoinLeaveHeatmaps(List events) { + int[][] joins = new int[7][24]; + int[][] leaves = new int[7][24]; + + for (JoinLeaveEventRepository.JoinLeaveEvent event : events) { + var dateTime = Instant.ofEpochMilli(event.createdAt()).atZone(ZoneId.systemDefault()); + int day = dateTime.getDayOfWeek().getValue() - 1; + int hour = dateTime.getHour(); + if (day < 0 || day > 6 || hour < 0 || hour > 23) { + continue; + } + + if ("JOIN".equalsIgnoreCase(event.eventType())) { + joins[day][hour] += 1; + } else if ("LEAVE".equalsIgnoreCase(event.eventType())) { + leaves[day][hour] += 1; + } + } + + List> joinCells = new ArrayList<>(); + List> leaveCells = new ArrayList<>(); + int maxJoin = 0; + int maxLeave = 0; + + for (int day = 0; day < 7; day++) { + for (int hour = 0; hour < 24; hour++) { + int joinCount = joins[day][hour]; + int leaveCount = leaves[day][hour]; + maxJoin = Math.max(maxJoin, joinCount); + maxLeave = Math.max(maxLeave, leaveCount); + + joinCells.add(Map.of("day", day, "hour", hour, "count", joinCount)); + leaveCells.add(Map.of("day", day, "hour", hour, "count", leaveCount)); + } + } + + return new JoinLeaveHeatmap(joinCells, leaveCells, maxJoin, maxLeave); + } + + private String handleBootstrap(Request request, Response response) { + if (!bootstrapService.needsBootstrap()) { + return json(response, 400, Map.of("error", "bootstrap_not_required")); + } + + BootstrapPayload payload = gson.fromJson(request.body(), BootstrapPayload.class); + if (payload == null || isBlank(payload.token()) || isBlank(payload.username()) || isBlank(payload.password())) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + + if (!bootstrapService.verifyToken(payload.token())) { + return json(response, 403, Map.of("error", "invalid_bootstrap_token")); + } + + if (!isValidUsername(payload.username()) || payload.password().length() < 10) { + return json(response, 400, Map.of("error", "weak_credentials")); + } + + PanelUser user = userRepository.createUser(payload.username().trim(), passwordHasher.hash(payload.password()), UserRole.OWNER); + panelLogger.log("AUTH", "BOOTSTRAP", "Initial owner account created: " + user.username()); + + String sessionToken = sessionService.createSession(user.id()); + setSessionCookie(response, sessionToken, config.sessionTtlMinutes() * 60); + return json(response, 200, Map.of("ok", true, "user", toPublicUser(user))); + } + + private String handleLogin(Request request, Response response) { + if (bootstrapService.needsBootstrap()) { + return json(response, 400, Map.of("error", "bootstrap_required")); + } + + LoginPayload payload = gson.fromJson(request.body(), LoginPayload.class); + if (payload == null || isBlank(payload.username()) || isBlank(payload.password())) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + + Optional authUser = userRepository.findByUsername(payload.username().trim()); + if (authUser.isEmpty() || !passwordHasher.verify(payload.password(), authUser.get().passwordHash())) { + panelLogger.log("AUTH", payload.username(), "Failed login attempt"); + return json(response, 401, Map.of("error", "invalid_credentials")); + } + + PanelUser user = authUser.get().user(); + String sessionToken = sessionService.createSession(user.id()); + setSessionCookie(response, sessionToken, config.sessionTtlMinutes() * 60); + panelLogger.log("AUTH", user.username(), "Login successful"); + + return json(response, 200, Map.of("ok", true, "user", toPublicUser(user))); + } + + private String handleLogout(Request request, Response response) { + String token = request.cookie(SESSION_COOKIE); + sessionService.deleteSession(token); + response.removeCookie(SESSION_COOKIE); + return json(response, 200, Map.of("ok", true)); + } + + private String handleMe(Request request, Response response) { + PanelUser user = requireUser(request, PanelPermission.ACCESS_PANEL); + return json(response, 200, Map.of("user", toPublicUser(user))); + } + + private String handleOAuthProviders(Response response) { + List> providers = new ArrayList<>(); + providers.add(oauthProviderPayload("google", "Google")); + providers.add(oauthProviderPayload("discord", "Discord")); + return json(response, 200, Map.of("providers", providers)); + } + + private String handleOAuthStart(Request request, Response response) { + if (bootstrapService.needsBootstrap()) { + return json(response, 400, Map.of("error", "bootstrap_required")); + } + + String provider = normalizeOAuthProvider(request.params("provider")); + WebPanelConfig.OAuthProviderConfig providerConfig = config.oauthProvider(provider); + if (!providerConfig.configured()) { + return json(response, 400, Map.of("error", "oauth_provider_not_configured")); + } + + OAuthStartPayload payload; + try { + String body = request.body(); + payload = (body == null || body.isBlank()) ? null : gson.fromJson(body, OAuthStartPayload.class); + } catch (JsonSyntaxException exception) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + String mode = payload == null ? OAUTH_MODE_LOGIN : normalizeOAuthMode(payload.mode()); + if (!OAUTH_MODE_LOGIN.equals(mode) && !OAUTH_MODE_LINK.equals(mode)) { + return json(response, 400, Map.of("error", "invalid_oauth_mode")); + } + + Long userId = null; + if (OAUTH_MODE_LINK.equals(mode)) { + userId = requireUser(request, PanelPermission.ACCESS_PANEL).id(); + } + + String state = generateToken(24); + long expiresAt = Instant.now().plus(config.oauthStateTtlMinutes(), ChronoUnit.MINUTES).toEpochMilli(); + oAuthStateRepository.createState(state, provider, mode, userId, expiresAt); + + String authUrl = buildOAuthAuthorizationUrl(provider, providerConfig, state); + return json(response, 200, Map.of("ok", true, "provider", provider, "mode", mode, "authUrl", authUrl)); + } + + private Object handleOAuthCallback(Request request, Response response) { + if (bootstrapService.needsBootstrap()) { + response.redirect("/setup?oauth=bootstrap_required"); + return ""; + } + + String provider = normalizeOAuthProvider(request.params("provider")); + WebPanelConfig.OAuthProviderConfig providerConfig = config.oauthProvider(provider); + if (!providerConfig.configured()) { + response.redirect("/?oauth=provider_not_configured"); + return ""; + } + + String callbackError = request.queryParams("error"); + if (!isBlank(callbackError)) { + response.redirect("/?oauth=" + urlEncode("provider_error:" + callbackError)); + return ""; + } + + String code = request.queryParams("code"); + String state = request.queryParams("state"); + if (isBlank(code) || isBlank(state)) { + response.redirect("/?oauth=invalid_callback"); + return ""; + } + + Optional oauthState = oAuthStateRepository.consumeState(state, provider, OAUTH_MODE_LOGIN); + String mode = OAUTH_MODE_LOGIN; + if (oauthState.isEmpty()) { + oauthState = oAuthStateRepository.consumeState(state, provider, OAUTH_MODE_LINK); + mode = OAUTH_MODE_LINK; + } + if (oauthState.isEmpty()) { + response.redirect("/?oauth=invalid_state"); + return ""; + } + + String modeFailureRedirect = OAUTH_MODE_LINK.equals(mode) ? "/dashboard/account" : "/"; + + OAuthUserProfile profile; + try { + String accessToken = exchangeOAuthCodeForAccessToken(provider, providerConfig, code); + profile = fetchOAuthUserProfile(provider, accessToken); + } catch (Exception exception) { + plugin.getLogger().warning("OAuth callback failed for provider=" + provider + ": " + exception.getMessage()); + response.redirect(modeFailureRedirect + "?oauth=exchange_failed"); + return ""; + } + + if (OAUTH_MODE_LINK.equals(mode)) { + Long userId = oauthState.get().userId(); + if (userId == null) { + response.redirect("/dashboard/account?oauth=invalid_state"); + return ""; + } + + Optional existingOwner = oAuthAccountRepository.findUserIdByProviderSubject(provider, profile.providerUserId()); + if (existingOwner.isPresent() && !Objects.equals(existingOwner.get(), userId)) { + response.redirect("/dashboard/account?oauth=already_linked"); + return ""; + } + + oAuthAccountRepository.upsertLink(userId, provider, profile.providerUserId(), profile.displayName(), profile.email(), profile.avatarUrl()); + panelLogger.log("AUTH", "OAUTH", "Linked " + provider + " account for panel user id=" + userId); + response.redirect("/dashboard/account?oauth=linked"); + return ""; + } + + Optional userId = oAuthAccountRepository.findUserIdByProviderSubject(provider, profile.providerUserId()); + if (userId.isEmpty()) { + response.redirect("/?oauth=not_linked"); + return ""; + } + + Optional user = userRepository.findById(userId.get()); + if (user.isEmpty()) { + response.redirect("/?oauth=user_not_found"); + return ""; + } + + String sessionToken = sessionService.createSession(user.get().id()); + setSessionCookie(response, sessionToken, config.sessionTtlMinutes() * 60); + panelLogger.log("AUTH", user.get().username(), "OAuth login successful via " + provider); + response.redirect("/dashboard/overview"); + return ""; + } + + private String handleOAuthUnlink(Request request, Response response) { + PanelUser user = requireUser(request, PanelPermission.ACCESS_PANEL); + String provider = normalizeOAuthProvider(request.params("provider")); + if (provider.isBlank()) { + return json(response, 400, Map.of("error", "invalid_oauth_provider")); + } + + boolean removed = oAuthAccountRepository.unlink(user.id(), provider); + if (!removed) { + return json(response, 404, Map.of("error", "oauth_link_not_found")); + } + + panelLogger.log("AUDIT", user.username(), "Unlinked OAuth provider " + provider); + return json(response, 200, Map.of("ok", true)); + } + + private String handleAccountLinks(Request request, Response response) { + PanelUser user = requireUser(request, PanelPermission.ACCESS_PANEL); + Map byProvider = new HashMap<>(); + for (OAuthAccountRepository.OAuthAccountLink link : oAuthAccountRepository.listByUserId(user.id())) { + byProvider.put(normalizeOAuthProvider(link.provider()), link); + } + + List> providers = new ArrayList<>(); + providers.add(accountProviderPayload("google", "Google", byProvider.get("google"))); + providers.add(accountProviderPayload("discord", "Discord", byProvider.get("discord"))); + return json(response, 200, Map.of("providers", providers)); + } + + private String handleListUsers(Request request, Response response) { + requireOwner(request); + + List> users = userRepository.findAllUsers().stream().map(this::toPublicUser).toList(); + return json(response, 200, Map.of( + "users", users, + "permissions", permissionCatalogForCurrentRuntime(), + "roleDefaults", roleDefaultsPayload() + )); + } + + private String handleCreateUser(Request request, Response response) { + PanelUser actingUser = requireOwner(request); + + CreateUserPayload payload = gson.fromJson(request.body(), CreateUserPayload.class); + if (payload == null || isBlank(payload.username()) || isBlank(payload.password()) || isBlank(payload.role())) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + + if (!isValidUsername(payload.username()) || payload.password().length() < 10) { + return json(response, 400, Map.of("error", "weak_credentials")); + } + + UserRole role; + try { + role = UserRole.fromString(payload.role()); + } catch (IllegalArgumentException exception) { + return json(response, 400, Map.of("error", "invalid_role")); + } + + if (role == UserRole.OWNER) { + return json(response, 403, Map.of("error", "owner_creation_blocked")); + } + + if (userRepository.findByUsername(payload.username().trim()).isPresent()) { + return json(response, 409, Map.of("error", "username_exists")); + } + + Set requestedPermissions = parsePermissionNames(payload.permissions()); + PanelUser created = userRepository.createUser(payload.username().trim(), passwordHasher.hash(payload.password()), role, requestedPermissions); + panelLogger.log("AUDIT", actingUser.username(), "Created panel user " + created.username() + " with role " + role.name()); + + return json(response, 201, Map.of("ok", true, "user", toPublicUser(created))); + } + + private String handleUpdateRole(Request request, Response response) { + PanelUser actingUser = requireOwner(request); + + String rawId = request.params("id"); + long userId; + try { + userId = Long.parseLong(rawId); + } catch (NumberFormatException exception) { + return json(response, 400, Map.of("error", "invalid_user_id")); + } + + UpdateRolePayload payload = gson.fromJson(request.body(), UpdateRolePayload.class); + if (payload == null || isBlank(payload.role())) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + + UserRole role; + try { + role = UserRole.fromString(payload.role()); + } catch (IllegalArgumentException exception) { + return json(response, 400, Map.of("error", "invalid_role")); + } + + Optional targetUser = userRepository.findById(userId); + if (targetUser.isEmpty()) { + return json(response, 404, Map.of("error", "user_not_found")); + } + + if (role == UserRole.OWNER) { + return json(response, 403, Map.of("error", "owner_creation_blocked")); + } + + if (targetUser.get().role() == UserRole.OWNER) { + return json(response, 403, Map.of("error", "owner_role_locked")); + } + + if (!userRepository.updateRole(userId, role)) { + return json(response, 500, Map.of("error", "role_update_failed")); + } + + panelLogger.log("AUDIT", actingUser.username(), "Changed role for " + targetUser.get().username() + " to " + role.name()); + return json(response, 200, Map.of("ok", true)); + } + + private String handleUpdatePermissions(Request request, Response response) { + PanelUser actingUser = requireOwner(request); + + String rawId = request.params("id"); + long userId; + try { + userId = Long.parseLong(rawId); + } catch (NumberFormatException exception) { + return json(response, 400, Map.of("error", "invalid_user_id")); + } + + Optional targetUser = userRepository.findById(userId); + if (targetUser.isEmpty()) { + return json(response, 404, Map.of("error", "user_not_found")); + } + if (targetUser.get().role() == UserRole.OWNER) { + return json(response, 403, Map.of("error", "owner_permissions_locked")); + } + + UpdatePermissionsPayload payload = gson.fromJson(request.body(), UpdatePermissionsPayload.class); + if (payload == null || payload.permissions() == null) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + + Set permissions = parsePermissionNames(payload.permissions()); + if (!userRepository.updatePermissions(userId, permissions)) { + return json(response, 500, Map.of("error", "permissions_update_failed")); + } + + panelLogger.log("AUDIT", actingUser.username(), "Updated permissions for " + targetUser.get().username()); + return json(response, 200, Map.of("ok", true)); + } + + private String handleDeleteUser(Request request, Response response) { + PanelUser actingUser = requireOwner(request); + + String rawId = request.params("id"); + long userId; + try { + userId = Long.parseLong(rawId); + } catch (NumberFormatException exception) { + return json(response, 400, Map.of("error", "invalid_user_id")); + } + + Optional targetUser = userRepository.findById(userId); + if (targetUser.isEmpty()) { + return json(response, 404, Map.of("error", "user_not_found")); + } + + PanelUser target = targetUser.get(); + if (target.role() == UserRole.OWNER) { + return json(response, 403, Map.of("error", "owner_deletion_blocked")); + } + + if (target.id() == actingUser.id()) { + return json(response, 403, Map.of("error", "self_deletion_blocked")); + } + + if (!userRepository.deleteUser(target.id())) { + return json(response, 500, Map.of("error", "user_delete_failed")); + } + + sessionService.deleteSessionsByUserId(target.id()); + panelLogger.log("AUDIT", actingUser.username(), "Deleted panel user " + target.username() + " (id=" + target.id() + ")"); + return json(response, 200, Map.of("ok", true)); + } + + private String handleLogs(Request request, Response response) { + requireUser(request, PanelPermission.VIEW_CONSOLE); + + int limit = parseInt(request.queryParams("limit"), 200, 1, 1000); + int consoleLines = parseInt(request.queryParams("consoleLines"), 200, 1, 2000); + String selectedConsoleFile = request.queryParams("consoleFile"); + if (selectedConsoleFile == null || selectedConsoleFile.isBlank()) { + selectedConsoleFile = "latest.log"; + } + + List panelLogs = logRepository.recentLogs(limit); + List latestConsole = serverLogService.readLogLines(selectedConsoleFile, consoleLines); + List availableConsoleFiles = serverLogService.listLogFiles(); + + if (!availableConsoleFiles.contains(selectedConsoleFile) && availableConsoleFiles.contains("latest.log")) { + selectedConsoleFile = "latest.log"; + latestConsole = serverLogService.readLogLines(selectedConsoleFile, consoleLines); + } + + Map payload = new HashMap<>(); + payload.put("panelLogs", panelLogs); + payload.put("latestConsole", latestConsole); + payload.put("selectedConsoleFile", selectedConsoleFile); + payload.put("availableConsoleFiles", availableConsoleFiles); + return json(response, 200, payload); + } + + private String handleLatestLogId(Request request, Response response) { + requireUser(request, PanelPermission.VIEW_CONSOLE); + return json(response, 200, Map.of("latestId", logRepository.latestLogId())); + } + + private String handlePlayers(Request request, Response response) { + requireUser(request, PanelPermission.VIEW_PLAYERS); + List> players = snapshotPlayerRoster(); + long onlineCount = players.stream() + .filter(player -> Boolean.TRUE.equals(player.get("online"))) + .count(); + + return json(response, 200, Map.of( + "count", onlineCount, + "players", players.stream().filter(player -> Boolean.TRUE.equals(player.get("online"))).toList(), + "allPlayers", players + )); + } + + private String handlePlayerProfile(Request request, Response response) { + requireUser(request, PanelPermission.VIEW_PLAYERS); + + UUID playerUuid; + try { + playerUuid = UUID.fromString(request.params("uuid")); + } catch (IllegalArgumentException exception) { + return json(response, 400, Map.of("error", "invalid_uuid")); + } + + Optional knownPlayer = knownPlayerRepository.findByUuid(playerUuid); + if (knownPlayer.isEmpty()) { + return json(response, 404, Map.of("error", "player_not_found")); + } + + Optional activity = playerActivityRepository.findByUuid(playerUuid); + boolean online = plugin.getServer().getPlayer(playerUuid) != null; + String username = knownPlayer.get().username(); + + long firstJoined = activity.map(PlayerActivity::firstJoined).orElse(0L); + long lastSeen = activity.map(PlayerActivity::lastSeen).orElse(knownPlayer.get().lastSeenAt()); + long totalSessions = activity.map(PlayerActivity::totalSessions).orElse(0L); + long totalPlaytimeSeconds = activity.map(PlayerActivity::totalPlaytimeSeconds).orElse(0L); + long currentSessionStart = activity.map(PlayerActivity::currentSessionStart).orElse(0L); + if (online && currentSessionStart > 0) { + long now = Instant.now().toEpochMilli(); + totalPlaytimeSeconds += Math.max(0L, (now - currentSessionStart) / 1000L); + } + + Map profile = new HashMap<>(); + profile.put("uuid", playerUuid.toString()); + profile.put("username", username); + profile.put("online", online); + profile.put("firstJoined", firstJoined); + profile.put("lastSeen", lastSeen); + profile.put("totalPlaytimeSeconds", totalPlaytimeSeconds); + profile.put("totalSessions", totalSessions); + profile.put("lastIp", activity.map(PlayerActivity::lastIp).orElse("")); + profile.put("country", activity.map(PlayerActivity::lastCountry).orElse("Unknown")); + + return json(response, 200, Map.of("profile", profile)); + } + + private String handleBanPlayer(Request request, Response response) { + PanelUser actingUser = requireUser(request, PanelPermission.MANAGE_PLAYERS); + + BanPayload payload = gson.fromJson(request.body(), BanPayload.class); + String username = resolveTargetUsername(payload == null ? null : payload.username(), payload == null ? null : payload.uuid()); + if (username == null) { + return json(response, 400, Map.of("error", "invalid_player")); + } + + String reason = isBlank(payload == null ? null : payload.reason()) ? "Banned by panel moderator" : payload.reason().trim(); + PlayerActionResult result = banPlayer(username, null, reason, actingUser.username()); + if (!result.success()) { + return json(response, 500, Map.of("error", result.error() == null ? "ban_failed" : result.error())); + } + + panelLogger.log("AUDIT", actingUser.username(), "Banned player " + username + ": " + reason); + return json(response, 200, Map.of("ok", true, "username", username)); + } + + private String handleUnbanPlayer(Request request, Response response) { + PanelUser actingUser = requireUser(request, PanelPermission.MANAGE_PLAYERS); + + UnbanPayload payload = gson.fromJson(request.body(), UnbanPayload.class); + String username = resolveTargetUsername(payload == null ? null : payload.username(), payload == null ? null : payload.uuid()); + if (username == null) { + return json(response, 400, Map.of("error", "invalid_player")); + } + + boolean removed = unbanPlayer(username); + if (!removed) { + return json(response, 404, Map.of("error", "player_not_banned")); + } + + panelLogger.log("AUDIT", actingUser.username(), "Unbanned player " + username); + return json(response, 200, Map.of("ok", true, "username", username)); + } + + private String handlePlugins(Request request, Response response) { + requireUser(request, PanelPermission.VIEW_PLUGINS); + List> plugins = snapshotInstalledPlugins(); + return json(response, 200, Map.of( + "count", plugins.size(), + "plugins", plugins + )); + } + + private String handlePluginMarketplaceSearch(Request request, Response response) { + requireUser(request, PanelPermission.MANAGE_PLUGINS); + + String source = normalizeMarketplaceSource(request.queryParams("source")); + String query = request.queryParams("query"); + if (isBlank(query)) { + return json(response, 400, Map.of("error", "query_required")); + } + + List> projects = switch (source) { + case "modrinth" -> searchModrinthProjects(query); + case "curseforge" -> searchCurseForgeProjects(query); + case "hangar" -> searchHangarProjects(query); + default -> List.of(); + }; + + return json(response, 200, Map.of( + "source", source, + "results", projects + )); + } + + private String handlePluginMarketplaceVersions(Request request, Response response) { + requireUser(request, PanelPermission.MANAGE_PLUGINS); + + String source = normalizeMarketplaceSource(request.queryParams("source")); + String projectId = request.queryParams("projectId"); + if (isBlank(projectId)) { + return json(response, 400, Map.of("error", "project_id_required")); + } + + List> versions = switch (source) { + case "modrinth" -> listModrinthVersions(projectId); + case "curseforge" -> listCurseForgeFiles(projectId); + case "hangar" -> listHangarVersions(projectId); + default -> List.of(); + }; + + return json(response, 200, Map.of( + "source", source, + "projectId", projectId, + "versions", versions + )); + } + + private String handlePluginMarketplaceInstall(Request request, Response response) { + PanelUser user = requireUser(request, PanelPermission.MANAGE_PLUGINS); + + PluginInstallPayload payload = gson.fromJson(request.body(), PluginInstallPayload.class); + if (payload == null || isBlank(payload.source()) || isBlank(payload.projectId()) || isBlank(payload.versionId())) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + + String source = normalizeMarketplaceSource(payload.source()); + MarketplaceDownload download; + try { + download = switch (source) { + case "modrinth" -> resolveModrinthDownload(payload.versionId()); + case "curseforge" -> resolveCurseForgeDownload(payload.projectId(), payload.versionId()); + case "hangar" -> resolveHangarDownload(payload.projectId(), payload.versionId()); + default -> null; + }; + } catch (Exception exception) { + return json(response, 500, Map.of("error", "marketplace_lookup_failed", "details", exception.getMessage())); + } + + if (download == null || isBlank(download.downloadUrl()) || isBlank(download.fileName())) { + return json(response, 404, Map.of("error", "download_not_found")); + } + + try { + Path pluginsDirectory = getPluginsDirectory(); + Files.createDirectories(pluginsDirectory); + + String safeFileName = sanitizePluginFileName(download.fileName()); + Path targetFile = pluginsDirectory.resolve(safeFileName).normalize(); + if (!targetFile.startsWith(pluginsDirectory)) { + return json(response, 400, Map.of("error", "invalid_file_name")); + } + + HttpRequest requestDownload = HttpRequest.newBuilder(URI.create(download.downloadUrl())).GET().build(); + HttpResponse downloadResponse = httpClient.send(requestDownload, HttpResponse.BodyHandlers.ofByteArray()); + if (downloadResponse.statusCode() >= 300) { + return json(response, 502, Map.of("error", "download_failed", "status", downloadResponse.statusCode())); + } + + Files.write(targetFile, downloadResponse.body(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + panelLogger.log("AUDIT", user.username(), "Installed plugin file " + safeFileName + " from " + source + " marketplace"); + + return json(response, 200, Map.of( + "ok", true, + "fileName", safeFileName, + "path", targetFile.toAbsolutePath().toString(), + "requiresRestart", true + )); + } catch (Exception exception) { + return json(response, 500, Map.of("error", "install_failed", "details", exception.getMessage())); + } + } + + private String handleUptime(Request request, Response response) { + requireUser(request, PanelPermission.VIEW_RESOURCES); + long now = System.currentTimeMillis(); + return json(response, 200, Map.of( + "startedAt", serverStartedAtMillis, + "uptimeMillis", Math.max(0, now - serverStartedAtMillis) + )); + } + + private String handleHealth(Request request, Response response) { + requireUser(request, PanelPermission.VIEW_RESOURCES); + + Runtime runtime = Runtime.getRuntime(); + long maxMemory = runtime.maxMemory(); + long totalMemory = runtime.totalMemory(); + long freeMemory = runtime.freeMemory(); + long usedMemory = totalMemory - freeMemory; + + double cpuSystemLoad = -1.0; + double cpuProcessLoad = -1.0; + int cpuCores = runtime.availableProcessors(); + java.lang.management.OperatingSystemMXBean mxBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean(); + if (mxBean instanceof com.sun.management.OperatingSystemMXBean sunBean) { + cpuSystemLoad = sunBean.getCpuLoad(); + cpuProcessLoad = sunBean.getProcessCpuLoad(); + } + + Map health = new HashMap<>(); + health.put("uptimeMillis", Math.max(0, System.currentTimeMillis() - serverStartedAtMillis)); + health.put("serverName", plugin.getServer().getName()); + health.put("serverVersion", plugin.getServer().getVersion()); + health.put("bukkitVersion", plugin.getServer().getBukkitVersion()); + health.put("onlinePlayers", plugin.getServer().getOnlinePlayers().size()); + health.put("maxPlayers", plugin.getServer().getMaxPlayers()); + health.put("cpuSystemLoad", cpuSystemLoad); + health.put("cpuProcessLoad", cpuProcessLoad); + health.put("cpuCores", cpuCores); + health.put("memoryUsed", usedMemory); + health.put("memoryTotal", totalMemory); + health.put("memoryMax", maxMemory); + + try { + var fileStore = java.nio.file.Files.getFileStore(plugin.getDataFolder().toPath()); + long totalSpace = fileStore.getTotalSpace(); + long freeSpace = fileStore.getUsableSpace(); + health.put("diskTotal", totalSpace); + health.put("diskUsed", totalSpace - freeSpace); + health.put("diskFree", freeSpace); + } catch (Exception ignored) { + health.put("diskTotal", -1L); + health.put("diskUsed", -1L); + health.put("diskFree", -1L); + } + + return json(response, 200, health); + } + + private String handleGetDiscordWebhook(Request request, Response response) { + requireUser(request, PanelPermission.VIEW_DISCORD_WEBHOOK); + return json(response, 200, toWebhookPayload(discordWebhookService.getConfig())); + } + + private String handleSaveDiscordWebhook(Request request, Response response) { + requireUser(request, PanelPermission.MANAGE_DISCORD_WEBHOOK); + + DiscordWebhookPayload payload = gson.fromJson(request.body(), DiscordWebhookPayload.class); + if (payload == null) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + + DiscordWebhookConfig previous = discordWebhookService.getConfig(); + String webhookUrl = normalizeWebhookUrl(payload.webhookUrl(), previous.webhookUrl()); + if (payload.enabled() != null && payload.enabled() && webhookUrl.isBlank()) { + return json(response, 400, Map.of("error", "webhook_url_required")); + } + + DiscordWebhookConfig updated = new DiscordWebhookConfig( + payload.enabled() == null ? previous.enabled() : payload.enabled(), + webhookUrl, + payload.useEmbed() == null ? previous.useEmbed() : payload.useEmbed(), + normalizeSimpleText(payload.botName(), previous.botName(), 80), + normalizeSimpleText(payload.messageTemplate(), previous.messageTemplate(), 600), + normalizeSimpleText(payload.embedTitleTemplate(), previous.embedTitleTemplate(), 120), + payload.logChat() == null ? previous.logChat() : payload.logChat(), + payload.logCommands() == null ? previous.logCommands() : payload.logCommands(), + payload.logAuth() == null ? previous.logAuth() : payload.logAuth(), + payload.logAudit() == null ? previous.logAudit() : payload.logAudit(), + payload.logSecurity() == null ? previous.logSecurity() : payload.logSecurity(), + payload.logConsoleResponse() == null ? previous.logConsoleResponse() : payload.logConsoleResponse(), + payload.logSystem() == null ? previous.logSystem() : payload.logSystem() + ); + + discordWebhookService.updateConfig(updated); + panelLogger.log("AUDIT", "WEBHOOK", "Discord webhook configuration updated"); + return json(response, 200, Map.of("ok", true, "config", toWebhookPayload(updated))); + } + + private String handleKickPlayer(Request request, Response response) { + PanelUser actingUser = requireUser(request, PanelPermission.MANAGE_PLAYERS); + + UUID playerUuid; + try { + playerUuid = UUID.fromString(request.params("uuid")); + } catch (IllegalArgumentException exception) { + return json(response, 400, Map.of("error", "invalid_uuid")); + } + + KickPayload payload = gson.fromJson(request.body(), KickPayload.class); + String reason = payload == null || isBlank(payload.reason()) ? "Kicked by panel moderator" : payload.reason().trim(); + + PlayerActionResult result = kickPlayerByUuid(playerUuid, reason); + if (!result.success()) { + return json(response, 404, Map.of("error", result.error() == null ? "player_not_online" : result.error())); + } + + panelLogger.log("AUDIT", actingUser.username(), "Kicked player " + result.username() + ": " + reason); + return json(response, 200, Map.of("ok", true, "username", result.username())); + } + + private String handleTempBanPlayer(Request request, Response response) { + PanelUser actingUser = requireUser(request, PanelPermission.MANAGE_PLAYERS); + + UUID playerUuid; + try { + playerUuid = UUID.fromString(request.params("uuid")); + } catch (IllegalArgumentException exception) { + return json(response, 400, Map.of("error", "invalid_uuid")); + } + + TempBanPayload payload = gson.fromJson(request.body(), TempBanPayload.class); + if (payload == null || payload.durationMinutes() == null) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + + int durationMinutes = Math.max(1, Math.min(43_200, payload.durationMinutes())); + String reason = isBlank(payload.reason()) ? "Temporarily banned by panel moderator" : payload.reason().trim(); + String resolvedUsername = resolveTargetUsername(payload.username(), playerUuid.toString()); + if (resolvedUsername == null) { + return json(response, 400, Map.of("error", "invalid_player")); + } + + TempBanResult result = tempBanPlayer(playerUuid, resolvedUsername, durationMinutes, reason, actingUser.username()); + if (!result.success()) { + return json(response, 404, Map.of("error", result.error() == null ? "ban_failed" : result.error())); + } + + panelLogger.log("AUDIT", actingUser.username(), "Temp-banned player " + result.username() + " for " + durationMinutes + " minute(s): " + reason); + return json(response, 200, Map.of( + "ok", true, + "username", result.username(), + "expiresAt", result.expiresAtMillis() + )); + } + + private String handleSendConsole(Request request, Response response) { + PanelUser user = requireUser(request, PanelPermission.SEND_CONSOLE); + + ConsolePayload payload = gson.fromJson(request.body(), ConsolePayload.class); + if (payload == null || isBlank(payload.message())) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + + String rawMessage = payload.message().trim(); + if (rawMessage.isEmpty()) { + return json(response, 400, Map.of("error", "empty_message")); + } + + if (rawMessage.contains("\n") || rawMessage.contains("\r")) { + return json(response, 400, Map.of("error", "invalid_message")); + } + + String command = rawMessage.startsWith("/") ? rawMessage.substring(1).trim() : rawMessage; + if (command.isEmpty()) { + return json(response, 400, Map.of("error", "invalid_message")); + } + + CommandDispatchResult dispatchResult = dispatchConsoleCommand(command); + panelLogger.log("AUDIT", user.username(), "Sent console command: " + command); + panelLogger.log("CONSOLE_RESPONSE", "SERVER", "Command '" + command + "' -> " + dispatchResult.response()); + + return json(response, 200, Map.of( + "ok", true, + "dispatched", dispatchResult.dispatched(), + "response", dispatchResult.response() + )); + } + + private List> snapshotPlayerRoster() { + List onlinePlayers = snapshotOnlinePlayers(); + long now = Instant.now().toEpochMilli(); + Map activityByUuid = playerActivityRepository.findAllByUuid(); + + for (OnlinePlayerSnapshot onlinePlayer : onlinePlayers) { + knownPlayerRepository.upsert(onlinePlayer.uuid(), onlinePlayer.username(), now); + } + + Map nameBans = snapshotNameBans(); + Map> byUuid = new HashMap<>(); + + for (KnownPlayer knownPlayer : knownPlayerRepository.findAll()) { + String username = knownPlayer.username(); + BanStatus banStatus = nameBans.get(username.toLowerCase()); + PlayerActivity activity = activityByUuid.get(knownPlayer.uuid()); + + long firstJoined = activity == null ? 0L : activity.firstJoined(); + long lastSeen = activity == null ? knownPlayer.lastSeenAt() : Math.max(knownPlayer.lastSeenAt(), activity.lastSeen()); + long totalPlaytimeSeconds = activity == null ? 0L : activity.totalPlaytimeSeconds(); + long totalSessions = activity == null ? 0L : activity.totalSessions(); + String lastIp = activity == null ? "" : activity.lastIp(); + String country = activity == null || isBlank(activity.lastCountry()) ? "Unknown" : activity.lastCountry(); + + Map player = new HashMap<>(); + player.put("uuid", knownPlayer.uuid().toString()); + player.put("username", username); + player.put("online", false); + player.put("firstJoined", firstJoined); + player.put("lastSeen", lastSeen); + player.put("totalPlaytimeSeconds", totalPlaytimeSeconds); + player.put("totalSessions", totalSessions); + player.put("lastIp", lastIp); + player.put("country", country); + player.put("banned", banStatus != null); + player.put("banExpiresAt", banStatus == null ? null : banStatus.expiresAtMillis()); + byUuid.put(knownPlayer.uuid(), player); + } + + for (OnlinePlayerSnapshot onlinePlayer : onlinePlayers) { + Map row = byUuid.computeIfAbsent(onlinePlayer.uuid(), ignored -> { + Map created = new HashMap<>(); + created.put("uuid", onlinePlayer.uuid().toString()); + return created; + }); + + BanStatus banStatus = nameBans.get(onlinePlayer.username().toLowerCase()); + row.put("username", onlinePlayer.username()); + row.put("online", true); + row.put("lastSeen", now); + row.putIfAbsent("firstJoined", 0L); + row.putIfAbsent("totalPlaytimeSeconds", 0L); + row.putIfAbsent("totalSessions", 0L); + row.putIfAbsent("lastIp", ""); + row.putIfAbsent("country", "Unknown"); + row.put("banned", banStatus != null); + row.put("banExpiresAt", banStatus == null ? null : banStatus.expiresAtMillis()); + + PlayerActivity activity = activityByUuid.get(onlinePlayer.uuid()); + if (activity != null) { + row.put("firstJoined", activity.firstJoined()); + row.put("totalSessions", activity.totalSessions()); + long onlinePlaytime = activity.totalPlaytimeSeconds(); + if (activity.currentSessionStart() > 0) { + onlinePlaytime += Math.max(0L, (now - activity.currentSessionStart()) / 1000L); + } + row.put("totalPlaytimeSeconds", onlinePlaytime); + row.put("lastIp", activity.lastIp()); + row.put("country", isBlank(activity.lastCountry()) ? "Unknown" : activity.lastCountry()); + } + } + + List> roster = new ArrayList<>(byUuid.values()); + roster.sort((left, right) -> { + boolean leftOnline = Boolean.TRUE.equals(left.get("online")); + boolean rightOnline = Boolean.TRUE.equals(right.get("online")); + if (leftOnline != rightOnline) { + return leftOnline ? -1 : 1; + } + + String leftName = String.valueOf(left.getOrDefault("username", "")); + String rightName = String.valueOf(right.getOrDefault("username", "")); + return leftName.compareToIgnoreCase(rightName); + }); + return roster; + } + + private List snapshotOnlinePlayers() { + try { + return plugin.schedulerBridge().callGlobal(() -> + plugin.getServer().getOnlinePlayers().stream() + .sorted(Comparator.comparing(Player::getName, String.CASE_INSENSITIVE_ORDER)) + .map(player -> new OnlinePlayerSnapshot(player.getUniqueId(), player.getName())) + .toList() + , 2, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while reading online players", exception); + } catch (ExecutionException | TimeoutException exception) { + throw new IllegalStateException("Could not read online players", exception); + } + } + + private Map snapshotNameBans() { + try { + return plugin.schedulerBridge().callGlobal(() -> { + Map bans = new HashMap<>(); + for (BanEntry banEntry : plugin.getServer().getBanList(BanList.Type.NAME).getBanEntries()) { + String target = banEntry.getTarget(); + if (target == null) { + continue; + } + Date expiration = banEntry.getExpiration(); + bans.put(target.toLowerCase(), new BanStatus(expiration == null ? null : expiration.getTime())); + } + return bans; + }, 2, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while reading bans", exception); + } catch (ExecutionException | TimeoutException exception) { + throw new IllegalStateException("Could not read bans", exception); + } + } + + private List> snapshotInstalledPlugins() { + try { + return plugin.schedulerBridge().callGlobal(() -> + java.util.Arrays.stream(plugin.getServer().getPluginManager().getPlugins()) + .sorted(Comparator.comparing(Plugin::getName, String.CASE_INSENSITIVE_ORDER)) + .map(current -> Map.of( + "name", current.getName(), + "version", current.getDescription().getVersion(), + "enabled", current.isEnabled(), + "main", current.getDescription().getMain(), + "authors", current.getDescription().getAuthors() + )) + .toList() + , 2, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while reading plugins", exception); + } catch (ExecutionException | TimeoutException exception) { + throw new IllegalStateException("Could not read installed plugins", exception); + } + } + + private CommandDispatchResult dispatchConsoleCommand(String command) { + try { + boolean dispatched = plugin.schedulerBridge().callGlobal( + () -> plugin.getServer().dispatchCommand(plugin.getServer().getConsoleSender(), command) + , 2, TimeUnit.SECONDS); + if (dispatched) { + return new CommandDispatchResult(true, "executed"); + } + return new CommandDispatchResult(false, "not_executed_or_unknown_command"); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return new CommandDispatchResult(false, "interrupted"); + } catch (ExecutionException | TimeoutException exception) { + return new CommandDispatchResult(false, "dispatch_error: " + exception.getClass().getSimpleName()); + } + } + + private PlayerActionResult kickPlayerByUuid(UUID playerUuid, String reason) { + try { + return plugin.schedulerBridge().callGlobal(() -> { + Player target = plugin.getServer().getPlayer(playerUuid); + if (target == null) { + return new PlayerActionResult(false, null, "player_not_online"); + } + String username = target.getName(); + target.kickPlayer(reason); + return new PlayerActionResult(true, username, null); + }, 2, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return new PlayerActionResult(false, null, "interrupted"); + } catch (ExecutionException | TimeoutException exception) { + return new PlayerActionResult(false, null, "dispatch_error"); + } + } + + private TempBanResult tempBanPlayer(UUID playerUuid, String username, int durationMinutes, String reason, String actingUsername) { + try { + return plugin.schedulerBridge().callGlobal(() -> { + Player onlinePlayer = plugin.getServer().getPlayer(playerUuid); + Date expiresAt = Date.from(Instant.now().plus(durationMinutes, ChronoUnit.MINUTES)); + plugin.getServer().getBanList(BanList.Type.NAME) + .addBan(username, reason, expiresAt, "WebPanel:" + actingUsername); + + if (onlinePlayer != null) { + onlinePlayer.kickPlayer("Temporarily banned for " + durationMinutes + " minute(s). Reason: " + reason); + } + + return new TempBanResult(true, username, expiresAt.getTime(), null); + }, 2, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return new TempBanResult(false, null, 0L, "interrupted"); + } catch (ExecutionException | TimeoutException exception) { + return new TempBanResult(false, null, 0L, "dispatch_error"); + } + } + + private PlayerActionResult banPlayer(String username, UUID playerUuid, String reason, String actingUsername) { + try { + return plugin.schedulerBridge().callGlobal(() -> { + plugin.getServer().getBanList(BanList.Type.NAME) + .addBan(username, reason, null, "WebPanel:" + actingUsername); + + Player onlineTarget = null; + if (playerUuid != null) { + onlineTarget = plugin.getServer().getPlayer(playerUuid); + } + if (onlineTarget == null) { + onlineTarget = plugin.getServer().getPlayerExact(username); + } + if (onlineTarget != null) { + onlineTarget.kickPlayer("Banned. Reason: " + reason); + } + + return new PlayerActionResult(true, username, null); + }, 2, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return new PlayerActionResult(false, null, "interrupted"); + } catch (ExecutionException | TimeoutException exception) { + return new PlayerActionResult(false, null, "dispatch_error"); + } + } + + private boolean unbanPlayer(String username) { + try { + return plugin.schedulerBridge().callGlobal(() -> { + BanEntry banEntry = plugin.getServer().getBanList(BanList.Type.NAME).getBanEntry(username); + if (banEntry == null) { + return false; + } + plugin.getServer().getBanList(BanList.Type.NAME).pardon(username); + return true; + }, 2, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return false; + } catch (ExecutionException | TimeoutException exception) { + return false; + } + } + + private String resolveTargetUsername(String usernameRaw, String uuidRaw) { + String sanitized = sanitizeUsername(usernameRaw); + if (sanitized != null) { + return sanitized; + } + + if (uuidRaw == null || uuidRaw.isBlank()) { + return null; + } + + try { + UUID uuid = UUID.fromString(uuidRaw); + return knownPlayerRepository.findByUuid(uuid) + .map(KnownPlayer::username) + .orElse(null); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private String normalizeMarketplaceSource(String source) { + if (source == null) { + return "modrinth"; + } + + String normalized = source.trim().toLowerCase(); + return switch (normalized) { + case "modrinth", "curseforge", "hangar" -> normalized; + default -> "modrinth"; + }; + } + + private List> searchModrinthProjects(String query) { + String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); + String encodedFacets = URLEncoder.encode( + "[[\"project_type:plugin\"],[\"categories:paper\",\"categories:spigot\",\"categories:bukkit\",\"categories:purpur\",\"categories:folia\"]]", + StandardCharsets.UTF_8 + ); + JsonObject root = requestJson("https://api.modrinth.com/v2/search?query=" + encodedQuery + "&limit=20&facets=" + encodedFacets); + + List> results = new ArrayList<>(); + JsonArray hits = root.getAsJsonArray("hits"); + if (hits == null) { + return results; + } + + for (JsonElement element : hits) { + JsonObject hit = element.getAsJsonObject(); + JsonArray categories = hit.getAsJsonArray("categories"); + JsonArray displayCategories = hit.getAsJsonArray("display_categories"); + if (!containsAnyIgnoreCase(categories, PAPER_PLUGIN_LOADERS) + && !containsAnyIgnoreCase(displayCategories, PAPER_PLUGIN_LOADERS)) { + continue; + } + + Map row = new HashMap<>(); + row.put("projectId", stringValue(hit, "project_id")); + row.put("name", stringValue(hit, "title")); + row.put("description", stringValue(hit, "description")); + row.put("author", stringValue(hit, "author")); + row.put("iconUrl", stringValue(hit, "icon_url")); + row.put("source", "modrinth"); + results.add(row); + } + return results; + } + + private List> listModrinthVersions(String projectId) { + String encodedProject = URLEncoder.encode(projectId, StandardCharsets.UTF_8); + JsonArray versions = requestJsonArray("https://api.modrinth.com/v2/project/" + encodedProject + "/version"); + + List> result = new ArrayList<>(); + for (JsonElement element : versions) { + JsonObject version = element.getAsJsonObject(); + JsonArray loaders = version.getAsJsonArray("loaders"); + if (!containsAnyIgnoreCase(loaders, PAPER_PLUGIN_LOADERS)) { + continue; + } + + JsonArray files = version.getAsJsonArray("files"); + if (files == null || files.isEmpty()) { + continue; + } + + JsonObject selectedFile = pickModrinthPrimaryFile(files); + if (selectedFile == null) { + continue; + } + + Map row = new HashMap<>(); + row.put("versionId", stringValue(version, "id")); + row.put("name", firstNonBlank(stringValue(version, "name"), stringValue(version, "version_number"), "Version")); + row.put("gameVersions", listStringValues(version.getAsJsonArray("game_versions"))); + row.put("fileName", stringValue(selectedFile, "filename")); + row.put("source", "modrinth"); + result.add(row); + } + return result; + } + + private MarketplaceDownload resolveModrinthDownload(String versionId) { + String encodedVersion = URLEncoder.encode(versionId, StandardCharsets.UTF_8); + JsonObject version = requestJson("https://api.modrinth.com/v2/version/" + encodedVersion); + JsonObject selectedFile = pickModrinthPrimaryFile(version.getAsJsonArray("files")); + if (selectedFile == null) { + return null; + } + + return new MarketplaceDownload( + stringValue(selectedFile, "url"), + stringValue(selectedFile, "filename") + ); + } + + private JsonObject pickModrinthPrimaryFile(JsonArray files) { + if (files == null || files.isEmpty()) { + return null; + } + + for (JsonElement element : files) { + JsonObject file = element.getAsJsonObject(); + String filename = stringValue(file, "filename"); + if (file.has("primary") && file.get("primary").getAsBoolean() && filename.endsWith(".jar")) { + return file; + } + } + for (JsonElement element : files) { + JsonObject file = element.getAsJsonObject(); + if (stringValue(file, "filename").endsWith(".jar")) { + return file; + } + } + return files.get(0).getAsJsonObject(); + } + + private boolean containsAnyIgnoreCase(JsonArray array, List expectedValues) { + if (array == null || expectedValues == null || expectedValues.isEmpty()) { + return false; + } + + for (JsonElement element : array) { + if (element == null || element.isJsonNull()) { + continue; + } + String value = element.getAsString(); + if (value == null) { + continue; + } + String lowered = value.toLowerCase(); + for (String expected : expectedValues) { + if (lowered.equals(expected)) { + return true; + } + } + } + return false; + } + + private List> searchCurseForgeProjects(String query) { + String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); + JsonObject root = requestJson("https://api.curse.tools/v1/cf/mods/search?gameId=432&classId=5&pageSize=20&searchFilter=" + encodedQuery); + + List> results = new ArrayList<>(); + JsonArray data = root.getAsJsonArray("data"); + if (data == null) { + return results; + } + + for (JsonElement element : data) { + JsonObject mod = element.getAsJsonObject(); + Map row = new HashMap<>(); + row.put("projectId", stringValue(mod, "id")); + row.put("name", stringValue(mod, "name")); + row.put("description", stringValue(mod, "summary")); + row.put("author", firstAuthorName(mod)); + row.put("iconUrl", nestedStringValue(mod, "logo", "thumbnailUrl")); + row.put("source", "curseforge"); + results.add(row); + } + return results; + } + + private List> listCurseForgeFiles(String projectId) { + String encodedProject = URLEncoder.encode(projectId, StandardCharsets.UTF_8); + JsonObject root = requestJson("https://api.curse.tools/v1/cf/mods/" + encodedProject + "/files?pageSize=25"); + JsonArray files = root.getAsJsonArray("data"); + + List> result = new ArrayList<>(); + if (files == null) { + return result; + } + + for (JsonElement element : files) { + JsonObject file = element.getAsJsonObject(); + if (file.has("isAvailable") && !file.get("isAvailable").getAsBoolean()) { + continue; + } + + Map row = new HashMap<>(); + row.put("versionId", stringValue(file, "id")); + row.put("name", firstNonBlank(stringValue(file, "displayName"), stringValue(file, "fileName"), "File")); + row.put("gameVersions", listStringValues(file.getAsJsonArray("gameVersions"))); + row.put("fileName", stringValue(file, "fileName")); + row.put("source", "curseforge"); + result.add(row); + } + return result; + } + + private MarketplaceDownload resolveCurseForgeDownload(String projectId, String versionId) { + String encodedProject = URLEncoder.encode(projectId, StandardCharsets.UTF_8); + String encodedVersion = URLEncoder.encode(versionId, StandardCharsets.UTF_8); + JsonObject file = requestJson("https://api.curse.tools/v1/cf/mods/" + encodedProject + "/files/" + encodedVersion); + JsonObject data = file.getAsJsonObject("data"); + if (data == null) { + data = file; + } + + return new MarketplaceDownload( + stringValue(data, "downloadUrl"), + stringValue(data, "fileName") + ); + } + + private List> searchHangarProjects(String query) { + String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); + JsonObject root = requestJson("https://hangar.papermc.io/api/v1/projects?query=" + encodedQuery + "&limit=20"); + + List> results = new ArrayList<>(); + JsonArray data = root.getAsJsonArray("result"); + if (data == null) { + return results; + } + + for (JsonElement element : data) { + JsonObject project = element.getAsJsonObject(); + JsonObject namespace = project.getAsJsonObject("namespace"); + if (namespace == null) { + continue; + } + + String owner = stringValue(namespace, "owner"); + String slug = stringValue(namespace, "slug"); + if (isBlank(owner) || isBlank(slug)) { + continue; + } + + Map row = new HashMap<>(); + row.put("projectId", owner + "/" + slug); + row.put("name", stringValue(project, "name")); + row.put("description", stringValue(project, "description")); + row.put("author", owner); + row.put("iconUrl", stringValue(project, "avatarUrl")); + row.put("source", "hangar"); + results.add(row); + } + return results; + } + + private List> listHangarVersions(String projectId) { + String[] parts = splitHangarProject(projectId); + if (parts == null) { + return List.of(); + } + + String encodedOwner = URLEncoder.encode(parts[0], StandardCharsets.UTF_8); + String encodedSlug = URLEncoder.encode(parts[1], StandardCharsets.UTF_8); + JsonObject root = requestJson("https://hangar.papermc.io/api/v1/projects/" + encodedOwner + "/" + encodedSlug + "/versions?limit=30"); + JsonArray versions = root.getAsJsonArray("result"); + + List> result = new ArrayList<>(); + if (versions == null) { + return result; + } + + for (JsonElement element : versions) { + JsonObject version = element.getAsJsonObject(); + JsonObject downloads = version.getAsJsonObject("downloads"); + if (downloads == null || !downloads.has("PAPER")) { + continue; + } + + JsonObject paper = downloads.getAsJsonObject("PAPER"); + JsonObject fileInfo = paper.getAsJsonObject("fileInfo"); + JsonObject platformDependencies = version.getAsJsonObject("platformDependencies"); + JsonArray paperVersions = platformDependencies == null ? null : platformDependencies.getAsJsonArray("PAPER"); + + Map row = new HashMap<>(); + row.put("versionId", stringValue(version, "name")); + row.put("name", stringValue(version, "name")); + row.put("gameVersions", listStringValues(paperVersions)); + row.put("fileName", fileInfo == null ? "" : stringValue(fileInfo, "name")); + row.put("source", "hangar"); + result.add(row); + } + return result; + } + + private MarketplaceDownload resolveHangarDownload(String projectId, String versionName) { + String[] parts = splitHangarProject(projectId); + if (parts == null) { + return null; + } + + String encodedOwner = URLEncoder.encode(parts[0], StandardCharsets.UTF_8); + String encodedSlug = URLEncoder.encode(parts[1], StandardCharsets.UTF_8); + String encodedVersion = URLEncoder.encode(versionName, StandardCharsets.UTF_8); + JsonObject root = requestJson("https://hangar.papermc.io/api/v1/projects/" + encodedOwner + "/" + encodedSlug + "/versions/" + encodedVersion); + JsonObject downloads = root.getAsJsonObject("downloads"); + if (downloads == null || !downloads.has("PAPER")) { + return null; + } + + JsonObject paper = downloads.getAsJsonObject("PAPER"); + JsonObject fileInfo = paper.getAsJsonObject("fileInfo"); + return new MarketplaceDownload( + stringValue(paper, "downloadUrl"), + fileInfo == null ? "" : stringValue(fileInfo, "name") + ); + } + + private String[] splitHangarProject(String projectId) { + if (isBlank(projectId)) { + return null; + } + + String[] parts = projectId.split("/"); + if (parts.length != 2 || isBlank(parts[0]) || isBlank(parts[1])) { + return null; + } + return new String[]{parts[0], parts[1]}; + } + + private JsonObject requestJson(String url) { + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() >= 300) { + throw new IllegalStateException("Request failed with status " + response.statusCode()); + } + return gson.fromJson(response.body(), JsonObject.class); + } catch (Exception exception) { + throw new IllegalStateException("Could not query marketplace API", exception); + } + } + + private JsonArray requestJsonArray(String url) { + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() >= 300) { + throw new IllegalStateException("Request failed with status " + response.statusCode()); + } + return gson.fromJson(response.body(), JsonArray.class); + } catch (Exception exception) { + throw new IllegalStateException("Could not query marketplace API", exception); + } + } + + private Path getPluginsDirectory() { + Path pluginDataFolder = plugin.getDataFolder().toPath(); + Path pluginsFolder = pluginDataFolder.getParent(); + if (pluginsFolder != null) { + return pluginsFolder; + } + return pluginDataFolder.resolve("plugins"); + } + + private String sanitizePluginFileName(String fileName) { + String raw = fileName == null ? "plugin.jar" : fileName; + String cleaned = raw.replace("\\", "").replace("/", "").trim(); + if (cleaned.isEmpty()) { + return "plugin.jar"; + } + return cleaned; + } + + private List listStringValues(JsonArray array) { + List values = new ArrayList<>(); + if (array == null) { + return values; + } + for (JsonElement element : array) { + if (element != null && !element.isJsonNull()) { + values.add(element.getAsString()); + } + } + return values; + } + + private String firstAuthorName(JsonObject mod) { + JsonArray authors = mod.getAsJsonArray("authors"); + if (authors == null || authors.isEmpty()) { + return ""; + } + JsonObject first = authors.get(0).getAsJsonObject(); + return stringValue(first, "name"); + } + + private String stringValue(JsonObject object, String key) { + if (object == null || !object.has(key) || object.get(key).isJsonNull()) { + return ""; + } + return object.get(key).getAsString(); + } + + private String nestedStringValue(JsonObject object, String parentKey, String key) { + if (object == null || !object.has(parentKey) || object.get(parentKey).isJsonNull()) { + return ""; + } + JsonObject parent = object.getAsJsonObject(parentKey); + return stringValue(parent, key); + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (!isBlank(value)) { + return value; + } + } + return ""; + } + + private Map toWebhookPayload(DiscordWebhookConfig config) { + Map payload = new HashMap<>(); + payload.put("enabled", config.enabled()); + payload.put("webhookUrl", config.webhookUrl()); + payload.put("useEmbed", config.useEmbed()); + payload.put("botName", config.botName()); + payload.put("messageTemplate", config.messageTemplate()); + payload.put("embedTitleTemplate", config.embedTitleTemplate()); + payload.put("logChat", config.logChat()); + payload.put("logCommands", config.logCommands()); + payload.put("logAuth", config.logAuth()); + payload.put("logAudit", config.logAudit()); + payload.put("logSecurity", config.logSecurity()); + payload.put("logConsoleResponse", config.logConsoleResponse()); + payload.put("logSystem", config.logSystem()); + return payload; + } + + private String normalizeWebhookUrl(String value, String fallback) { + if (value == null) { + return fallback == null ? "" : fallback; + } + + String trimmed = value.trim(); + if (trimmed.isEmpty()) { + return ""; + } + + if (!(trimmed.startsWith("https://") || trimmed.startsWith("http://"))) { + return fallback == null ? "" : fallback; + } + return trimmed; + } + + private String normalizeSimpleText(String value, String fallback, int maxLength) { + String raw = value == null ? fallback : value; + if (raw == null || raw.isBlank()) { + return fallback == null ? "" : fallback; + } + String trimmed = raw.trim(); + if (trimmed.length() <= maxLength) { + return trimmed; + } + return trimmed.substring(0, maxLength); + } + + private Map oauthProviderPayload(String provider, String label) { + WebPanelConfig.OAuthProviderConfig providerConfig = config.oauthProvider(provider); + return Map.of( + "provider", provider, + "label", label, + "enabled", providerConfig.enabled(), + "configured", providerConfig.configured() + ); + } + + private Map accountProviderPayload(String provider, String label, OAuthAccountRepository.OAuthAccountLink link) { + WebPanelConfig.OAuthProviderConfig providerConfig = config.oauthProvider(provider); + Map payload = new HashMap<>(); + payload.put("provider", provider); + payload.put("label", label); + payload.put("enabled", providerConfig.enabled()); + payload.put("configured", providerConfig.configured()); + + if (link == null) { + payload.put("linked", false); + return payload; + } + + payload.put("linked", true); + payload.put("displayName", link.displayName()); + payload.put("email", link.email()); + payload.put("avatarUrl", link.avatarUrl()); + payload.put("linkedAt", link.linkedAt()); + payload.put("updatedAt", link.updatedAt()); + return payload; + } + + private String normalizeOAuthProvider(String rawProvider) { + if (rawProvider == null) { + return ""; + } + + String normalized = rawProvider.trim().toLowerCase(Locale.ROOT); + if ("google".equals(normalized) || "discord".equals(normalized)) { + return normalized; + } + return ""; + } + + private String normalizeOAuthMode(String rawMode) { + if (rawMode == null) { + return OAUTH_MODE_LOGIN; + } + + String normalized = rawMode.trim().toLowerCase(Locale.ROOT); + if (OAUTH_MODE_LINK.equals(normalized)) { + return OAUTH_MODE_LINK; + } + return OAUTH_MODE_LOGIN; + } + + private String buildOAuthAuthorizationUrl(String provider, WebPanelConfig.OAuthProviderConfig providerConfig, String state) { + String authorizationEndpoint; + String scope; + if ("google".equals(provider)) { + authorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"; + scope = "openid profile email"; + } else if ("discord".equals(provider)) { + authorizationEndpoint = "https://discord.com/api/oauth2/authorize"; + scope = "identify email"; + } else { + throw new IllegalArgumentException("Unsupported OAuth provider: " + provider); + } + + return authorizationEndpoint + + "?client_id=" + urlEncode(providerConfig.clientId()) + + "&redirect_uri=" + urlEncode(providerConfig.redirectUri()) + + "&response_type=code" + + "&scope=" + urlEncode(scope) + + "&state=" + urlEncode(state) + + "&prompt=" + urlEncode("select_account"); + } + + private String exchangeOAuthCodeForAccessToken(String provider, WebPanelConfig.OAuthProviderConfig providerConfig, String code) + throws IOException, InterruptedException { + String endpoint; + if ("google".equals(provider)) { + endpoint = "https://oauth2.googleapis.com/token"; + } else if ("discord".equals(provider)) { + endpoint = "https://discord.com/api/oauth2/token"; + } else { + throw new IllegalArgumentException("Unsupported OAuth provider: " + provider); + } + + String body = "grant_type=authorization_code" + + "&code=" + urlEncode(code) + + "&client_id=" + urlEncode(providerConfig.clientId()) + + "&client_secret=" + urlEncode(providerConfig.clientSecret()) + + "&redirect_uri=" + urlEncode(providerConfig.redirectUri()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(endpoint)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException("OAuth token exchange failed with status " + response.statusCode()); + } + + JsonObject payload = JsonParser.parseString(response.body()).getAsJsonObject(); + String accessToken = payload.has("access_token") ? payload.get("access_token").getAsString() : ""; + if (accessToken.isBlank()) { + throw new IllegalStateException("OAuth provider did not return access_token"); + } + + return accessToken; + } + + private OAuthUserProfile fetchOAuthUserProfile(String provider, String accessToken) + throws IOException, InterruptedException { + String endpoint; + if ("google".equals(provider)) { + endpoint = "https://www.googleapis.com/oauth2/v2/userinfo"; + } else if ("discord".equals(provider)) { + endpoint = "https://discord.com/api/users/@me"; + } else { + throw new IllegalArgumentException("Unsupported OAuth provider: " + provider); + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(endpoint)) + .header("Authorization", "Bearer " + accessToken) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException("OAuth userinfo failed with status " + response.statusCode()); + } + + JsonObject profile = JsonParser.parseString(response.body()).getAsJsonObject(); + if ("google".equals(provider)) { + String providerUserId = asString(profile, "id"); + if (providerUserId.isBlank()) { + throw new IllegalStateException("Google user info missing id"); + } + return new OAuthUserProfile( + provider, + providerUserId, + asString(profile, "name"), + asString(profile, "email"), + asString(profile, "picture") + ); + } + + String providerUserId = asString(profile, "id"); + if (providerUserId.isBlank()) { + throw new IllegalStateException("Discord user info missing id"); + } + + String globalName = asString(profile, "global_name"); + String username = asString(profile, "username"); + String discriminator = asString(profile, "discriminator"); + String displayName = !globalName.isBlank() ? globalName : username; + if (displayName.isBlank()) { + displayName = providerUserId; + } + if (!username.isBlank() && !"0".equals(discriminator) && !discriminator.isBlank()) { + displayName = username + "#" + discriminator; + } + + String avatar = asString(profile, "avatar"); + String avatarUrl = ""; + if (!avatar.isBlank()) { + avatarUrl = "https://cdn.discordapp.com/avatars/" + providerUserId + "/" + avatar + ".png?size=128"; + } + + return new OAuthUserProfile(provider, providerUserId, displayName, asString(profile, "email"), avatarUrl); + } + + private String asString(JsonObject object, String key) { + if (object == null || key == null || !object.has(key) || object.get(key).isJsonNull()) { + return ""; + } + try { + return object.get(key).getAsString(); + } catch (Exception ignored) { + return ""; + } + } + + private String generateToken(int byteCount) { + byte[] bytes = new byte[Math.max(16, byteCount)]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String urlEncode(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + private String sanitizeUsername(String rawUsername) { + if (rawUsername == null || rawUsername.isBlank()) { + return null; + } + + String trimmed = rawUsername.trim(); + if (!trimmed.matches("^[a-zA-Z0-9_]{3,16}$")) { + return null; + } + return trimmed; + } + + private PanelUser requireUser(Request request, PanelPermission permission) { + String token = request.cookie(SESSION_COOKIE); + if (isBlank(token)) { + throw halt(401, gson.toJson(Map.of("error", "unauthorized"))); + } + + Optional userId = sessionService.resolveUserId(token); + if (userId.isEmpty()) { + throw halt(401, gson.toJson(Map.of("error", "invalid_session"))); + } + + Optional user = userRepository.findById(userId.get()); + if (user.isEmpty()) { + throw halt(401, gson.toJson(Map.of("error", "user_not_found"))); + } + + if (!user.get().hasPermission(permission)) { + throw halt(403, gson.toJson(Map.of("error", "forbidden"))); + } + + return user.get(); + } + + private PanelUser requireOwner(Request request) { + PanelUser user = requireUser(request, PanelPermission.MANAGE_USERS); + if (user.role() != UserRole.OWNER) { + throw halt(403, gson.toJson(Map.of("error", "owner_required"))); + } + return user; + } + + private Map toPublicUser(PanelUser user) { + Map payload = new HashMap<>(); + payload.put("id", user.id()); + payload.put("username", user.username()); + payload.put("role", user.role().name()); + payload.put("isOwner", user.role() == UserRole.OWNER); + payload.put("createdAt", user.createdAt()); + payload.put("permissions", user.permissions().stream().map(Enum::name).sorted().toList()); + return payload; + } + + private List> permissionCatalogForCurrentRuntime() { + Set installedExtensions = installedExtensionIds(); + List> permissions = new ArrayList<>(); + + for (PanelPermission permission : PanelPermission.values()) { + if (!permission.assignable()) { + continue; + } + + String extensionId = permission.extensionId(); + if (extensionId != null && !installedExtensions.contains(normalizeExtensionKey(extensionId))) { + continue; + } + + permissions.add(Map.of( + "key", permission.name(), + "label", permission.label(), + "category", permission.category(), + "extensionId", extensionId == null ? "" : extensionId + )); + } + + permissions.sort(Comparator + .comparing((Map item) -> String.valueOf(item.get("category")), String.CASE_INSENSITIVE_ORDER) + .thenComparing(item -> String.valueOf(item.get("label")), String.CASE_INSENSITIVE_ORDER)); + return permissions; + } + + private Map> roleDefaultsPayload() { + Map> payload = new HashMap<>(); + for (UserRole role : UserRole.values()) { + List defaults = role.defaultPermissions().stream() + .filter(PanelPermission::assignable) + .map(Enum::name) + .sorted() + .toList(); + payload.put(role.name(), defaults); + } + return payload; + } + + private Set installedExtensionIds() { + Set ids = new HashSet<>(); + for (Map installed : extensionManager.installedExtensions()) { + String id = normalizeExtensionKey(String.valueOf(installed.getOrDefault("id", ""))); + if (!id.isBlank()) { + ids.add(id); + } + } + return ids; + } + + private Set parsePermissionNames(List rawPermissions) { + Set parsed = EnumSet.noneOf(PanelPermission.class); + if (rawPermissions == null) { + return parsed; + } + + for (String raw : rawPermissions) { + if (raw == null || raw.isBlank()) { + continue; + } + try { + parsed.add(PanelPermission.valueOf(raw.trim().toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException ignored) { + // Ignore unknown permission ids from stale clients. + } + } + return parsed; + } + + private void setSessionCookie(Response response, String token, int maxAgeSeconds) { + response.raw().addHeader( + "Set-Cookie", + SESSION_COOKIE + "=" + token + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=" + maxAgeSeconds + ); + } + + private String json(Response response, int status, Map payload) { + response.status(status); + response.type("application/json"); + return gson.toJson(payload); + } + + private Object serveStaticWebAsset(Request request, Response response, String fileName, String contentType) { + long lastModifiedMillis = webAssetService.lastModifiedMillis(fileName); + String eTag = "W/\"" + fileName + "-" + lastModifiedMillis + "\""; + + response.header("Cache-Control", "public, max-age=60, must-revalidate"); + response.header("ETag", eTag); + if (lastModifiedMillis > 0L) { + response.header("Last-Modified", formatHttpDate(lastModifiedMillis)); + } + + String ifNoneMatch = request.headers("If-None-Match"); + if (ifNoneMatch != null && ifNoneMatch.equals(eTag)) { + response.status(304); + return ""; + } + + long ifModifiedSince = readIfModifiedSince(request); + if (ifModifiedSince > 0L && lastModifiedMillis > 0L) { + long normalizedServerMillis = (lastModifiedMillis / 1000L) * 1000L; + if (normalizedServerMillis <= ifModifiedSince) { + response.status(304); + return ""; + } + } + + response.type(contentType); + return webAssetService.readText(fileName); + } + + private long readIfModifiedSince(Request request) { + try { + return request.raw().getDateHeader("If-Modified-Since"); + } catch (IllegalArgumentException ignored) { + return -1L; + } + } + + private String formatHttpDate(long epochMillis) { + return DateTimeFormatter.RFC_1123_DATE_TIME.format(Instant.ofEpochMilli(epochMillis).atZone(ZoneId.of("GMT"))); + } + + private int parseInt(String rawValue, int fallback, int min, int max) { + if (rawValue == null || rawValue.isBlank()) { + return fallback; + } + try { + int parsed = Integer.parseInt(rawValue); + return Math.max(min, Math.min(max, parsed)); + } catch (NumberFormatException exception) { + return fallback; + } + } + + private boolean isValidUsername(String username) { + return username != null && username.matches("^[a-zA-Z0-9_.-]{3,32}$"); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String handleExtensionNavigation(Request request, Response response) { + PanelUser user = requireUser(request, PanelPermission.ACCESS_PANEL); + + List> tabs = extensionManager.navigationTabs().stream() + .filter(tab -> { + PanelPermission required = permissionForPath(tab.path()); + return required == null || user.hasPermission(required); + }) + .map(tab -> Map.of( + "category", tab.category(), + "label", tab.label(), + "path", tab.path() + )) + .toList(); + + return json(response, 200, Map.of("tabs", tabs)); + } + + private PanelPermission permissionForPath(String path) { + if (path == null || path.isBlank()) { + return null; + } + + return switch (path.trim()) { + case "/dashboard/overview" -> PanelPermission.VIEW_OVERVIEW; + case "/console", "/dashboard/console" -> PanelPermission.VIEW_CONSOLE; + case "/dashboard/resources" -> PanelPermission.VIEW_RESOURCES; + case "/dashboard/players" -> PanelPermission.VIEW_PLAYERS; + case "/dashboard/bans" -> PanelPermission.VIEW_BANS; + case "/dashboard/plugins" -> PanelPermission.VIEW_PLUGINS; + case "/dashboard/users" -> PanelPermission.VIEW_USERS; + case "/dashboard/discord-webhook" -> PanelPermission.VIEW_DISCORD_WEBHOOK; + case "/dashboard/themes" -> PanelPermission.VIEW_THEMES; + case "/dashboard/extensions" -> PanelPermission.VIEW_EXTENSIONS; + case "/dashboard/extension-config" -> PanelPermission.VIEW_EXTENSIONS; + case "/dashboard/world-backups" -> PanelPermission.VIEW_BACKUPS; + case "/dashboard/reports" -> PanelPermission.VIEW_REPORTS; + case "/dashboard/tickets" -> PanelPermission.VIEW_TICKETS; + case "/dashboard/maintenance" -> PanelPermission.VIEW_MAINTENANCE; + case "/dashboard/whitelist" -> PanelPermission.VIEW_WHITELIST; + case "/dashboard/announcements" -> PanelPermission.VIEW_ANNOUNCEMENTS; + default -> null; + }; + } + + private String handleExtensionStatus(Request request, Response response) { + requireUser(request, PanelPermission.VIEW_EXTENSIONS); + + String channel = normalizeReleaseChannel(request.queryParams("channel")); + List> installed = extensionManager.installedExtensions(); + List> localArtifacts = extensionManager.availableArtifacts(); + List> availableExtensions = loadAvailableExtensionsFromGitHub(channel); + + Map latestByExtensionId = new HashMap<>(); + for (Map extension : availableExtensions) { + String extensionId = normalizeExtensionKey(String.valueOf(extension.getOrDefault("extensionId", ""))); + if (extensionId.isBlank()) { + continue; + } + + String latestVersionLabel = String.valueOf(extension.getOrDefault("version", "")); + VersionToken latestVersion = parseVersionToken(latestVersionLabel); + ExtensionVersionInfo existing = latestByExtensionId.get(extensionId); + if (existing == null || compareVersionTokens(latestVersion, existing.versionToken()) > 0) { + latestByExtensionId.put(extensionId, new ExtensionVersionInfo(latestVersionLabel, latestVersion)); + } + } + + List> installedWithStatus = new ArrayList<>(); + Set installedIds = new HashSet<>(); + Set outdatedInstalledIds = new HashSet<>(); + for (Map installedEntry : installed) { + Map row = new HashMap<>(installedEntry); + String source = String.valueOf(installedEntry.getOrDefault("source", "")); + String extensionId = normalizeExtensionKey(String.valueOf(installedEntry.getOrDefault("id", ""))); + String sourceExtensionId = normalizeExtensionKey(parseExtensionId(source)); + String installedVersionLabel = extractVersionLabel(source); + VersionToken installedVersion = parseVersionToken(installedVersionLabel); + + if (!extensionId.isBlank()) { + installedIds.add(extensionId); + } + if (!sourceExtensionId.isBlank()) { + installedIds.add(sourceExtensionId); + } + + ExtensionVersionInfo latest = latestByExtensionId.get(extensionId); + if (latest == null && !sourceExtensionId.isBlank()) { + latest = latestByExtensionId.get(sourceExtensionId); + } + if (latest == null) { + row.put("status", "unknown"); + row.put("outdated", false); + row.put("statusText", "No release asset found"); + } else { + int comparison = compareVersionTokens(installedVersion, latest.versionToken()); + if (comparison < 0) { + row.put("status", "outdated"); + row.put("outdated", true); + row.put("statusText", "Outdated (latest: " + latest.versionLabel() + ")"); + row.put("latestVersion", latest.versionLabel()); + if (!extensionId.isBlank()) { + outdatedInstalledIds.add(extensionId); + } + if (!sourceExtensionId.isBlank()) { + outdatedInstalledIds.add(sourceExtensionId); + } + } else if (comparison >= 0) { + row.put("status", "up_to_date"); + row.put("outdated", false); + row.put("statusText", "Up to date"); + row.put("latestVersion", latest.versionLabel()); + } else { + row.put("status", "unknown"); + row.put("outdated", false); + row.put("statusText", "Version unknown (latest: " + latest.versionLabel() + ")"); + row.put("latestVersion", latest.versionLabel()); + } + } + installedWithStatus.add(row); + } + + List> availableWithInstallState = new ArrayList<>(); + Set restartRequiredSnapshot; + synchronized (restartRequiredExtensionIds) { + restartRequiredSnapshot = new HashSet<>(restartRequiredExtensionIds); + } + for (Map availableEntry : availableExtensions) { + Map row = new HashMap<>(availableEntry); + String extensionId = normalizeExtensionKey(String.valueOf(availableEntry.getOrDefault("extensionId", ""))); + if (!extensionId.isBlank() && restartRequiredSnapshot.contains(extensionId)) { + row.put("installState", "restart_required"); + } else if (!extensionId.isBlank() && outdatedInstalledIds.contains(extensionId)) { + row.put("installState", "outdated"); + } else if (!extensionId.isBlank() && installedIds.contains(extensionId)) { + row.put("installState", "installed"); + } else { + row.put("installState", "not_installed"); + } + availableWithInstallState.add(row); + } + + Map payload = new HashMap<>(); + payload.put("installed", installedWithStatus); + payload.put("localArtifacts", localArtifacts); + payload.put("availableExtensions", availableWithInstallState); + payload.put("channel", channel); + payload.put("installedCount", installedWithStatus.size()); + payload.put("localArtifactCount", localArtifacts.size()); + payload.put("availableExtensionCount", availableWithInstallState.size()); + payload.put("restartRequiredCount", restartRequiredSnapshot.size()); + if (!lastGitHubCatalogError.isBlank()) { + payload.put("availableExtensionsWarning", lastGitHubCatalogError); + } + return json(response, 200, payload); + } + + private String handleExtensionConfigList(Request request, Response response) { + requireUser(request, PanelPermission.VIEW_EXTENSIONS); + + List> extensions = extensionManager.installedExtensions().stream() + .filter(entry -> extensionHasConfig(normalizeExtensionKey(String.valueOf(entry.getOrDefault("id", "")))) + ) + .map(entry -> { + String extensionId = normalizeExtensionKey(String.valueOf(entry.getOrDefault("id", ""))); + String settingsJson = extensionSettingsRepository.findSettingsJson(extensionId).orElse("{}"); + JsonObject settings = parseSettingsObject(settingsJson); + + Map row = new HashMap<>(entry); + row.put("settings", settings == null ? new JsonObject() : settings); + row.put("settingsJson", settingsJson); + return row; + }) + .toList(); + + return json(response, 200, Map.of("extensions", extensions)); + } + + private String handleExtensionConfigGet(Request request, Response response) { + requireUser(request, PanelPermission.VIEW_EXTENSIONS); + + String extensionId = normalizeExtensionKey(request.params("id")); + if (extensionId.isBlank()) { + return json(response, 400, Map.of("error", "invalid_extension_id")); + } + + if (!extensionHasConfig(extensionId)) { + return json(response, 404, Map.of("error", "extension_has_no_config")); + } + + if (!isInstalledExtension(extensionId)) { + return json(response, 404, Map.of("error", "extension_not_installed")); + } + + String settingsJson = extensionSettingsRepository.findSettingsJson(extensionId).orElse("{}"); + JsonObject settings = parseSettingsObject(settingsJson); + return json(response, 200, Map.of( + "extensionId", extensionId, + "settings", settings == null ? new JsonObject() : settings, + "settingsJson", settingsJson + )); + } + + private String handleExtensionConfigSave(Request request, Response response) { + PanelUser user = requireUser(request, PanelPermission.MANAGE_EXTENSIONS); + + String extensionId = normalizeExtensionKey(request.params("id")); + if (extensionId.isBlank()) { + return json(response, 400, Map.of("error", "invalid_extension_id")); + } + + if (!extensionHasConfig(extensionId)) { + return json(response, 404, Map.of("error", "extension_has_no_config")); + } + + if (!isInstalledExtension(extensionId)) { + return json(response, 404, Map.of("error", "extension_not_installed")); + } + + JsonObject payload; + try { + payload = gson.fromJson(request.body(), JsonObject.class); + } catch (JsonSyntaxException exception) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + + if (payload == null || !payload.has("settings") || payload.get("settings") == null || !payload.get("settings").isJsonObject()) { + return json(response, 400, Map.of("error", "invalid_settings_payload")); + } + + JsonObject settings = payload.getAsJsonObject("settings"); + String settingsJson = gson.toJson(settings); + extensionSettingsRepository.saveSettingsJson(extensionId, settingsJson, System.currentTimeMillis()); + boolean liveApplied = extensionManager.applySettings(extensionId, settingsJson); + panelLogger.log("AUDIT", user.username(), "Updated extension settings for " + extensionId); + + return json(response, 200, Map.of( + "ok", true, + "extensionId", extensionId, + "liveApplied", liveApplied, + "settings", settings, + "settingsJson", settingsJson + )); + } + + private boolean isInstalledExtension(String extensionId) { + if (extensionId == null || extensionId.isBlank()) { + return false; + } + return extensionManager.installedExtensions().stream() + .map(entry -> normalizeExtensionKey(String.valueOf(entry.getOrDefault("id", "")))) + .anyMatch(installedId -> installedId.equals(extensionId)); + } + + private boolean extensionHasConfig(String extensionId) { + if (extensionId == null || extensionId.isBlank()) { + return false; + } + + return extensionManager.supportsSettings(extensionId); + } + + private JsonObject parseSettingsObject(String settingsJson) { + if (isBlank(settingsJson)) { + return new JsonObject(); + } + try { + JsonElement element = gson.fromJson(settingsJson, JsonElement.class); + if (element != null && element.isJsonObject()) { + return element.getAsJsonObject(); + } + } catch (Exception ignored) { + // Ignore malformed persisted settings and return empty defaults. + } + return new JsonObject(); + } + + private String handleExtensionInstall(Request request, Response response) { + PanelUser userSession = requireUser(request, PanelPermission.MANAGE_EXTENSIONS); + + JsonObject payload; + try { + payload = gson.fromJson(request.body(), JsonObject.class); + } catch (JsonSyntaxException exception) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + if (payload == null) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + + String channel = normalizeReleaseChannel(stringValue(payload, "channel")); + String extensionId = normalizeExtensionKey(stringValue(payload, "extensionId")); + String fileName = stringValue(payload, "fileName"); + if (extensionId.isBlank() || isBlank(fileName)) { + return json(response, 400, Map.of("error", "invalid_payload")); + } + + List> available = loadAvailableExtensionsFromGitHub(channel); + Map selected = null; + for (Map row : available) { + String rowId = normalizeExtensionKey(String.valueOf(row.getOrDefault("extensionId", ""))); + String rowFile = String.valueOf(row.getOrDefault("id", "")); + if (extensionId.equals(rowId) && fileName.equals(rowFile)) { + selected = row; + break; + } + } + + if (selected == null) { + return json(response, 404, Map.of("error", "extension_asset_not_found")); + } + + String downloadUrl = String.valueOf(selected.getOrDefault("downloadUrl", "")); + if (isBlank(downloadUrl)) { + return json(response, 502, Map.of("error", "missing_download_url")); + } + + if (!isValidExtensionJarFileName(fileName)) { + return json(response, 400, Map.of("error", "invalid_file_name")); + } + + try { + Path extensionDirectory = plugin.getDataFolder().toPath().resolve("extensions"); + Files.createDirectories(extensionDirectory); + + Path destination = extensionDirectory.resolve(fileName).normalize(); + if (!destination.startsWith(extensionDirectory)) { + return json(response, 400, Map.of("error", "invalid_file_name")); + } + + ArtifactCleanupResult cleanupResult = removeExistingExtensionArtifacts(extensionDirectory, extensionId, fileName); + downloadExtensionAsset(downloadUrl, destination); + + synchronized (restartRequiredExtensionIds) { + restartRequiredExtensionIds.add(extensionId); + } + + panelLogger.log("PANEL", "EXTENSIONS", "Downloaded extension " + fileName + " to " + destination.getFileName() + " by " + userSession.username()); + if (!cleanupResult.removedArtifacts().isEmpty()) { + panelLogger.log("PANEL", "EXTENSIONS", "Removed old extension artifact(s) for " + extensionId + ": " + String.join(", ", cleanupResult.removedArtifacts())); + } + if (!cleanupResult.pendingCleanupArtifacts().isEmpty()) { + panelLogger.log("PANEL", "EXTENSIONS", "Extension artifact(s) still in use for " + extensionId + ": " + String.join(", ", cleanupResult.pendingCleanupArtifacts())); + } + + Map responsePayload = new HashMap<>(); + responsePayload.put("ok", true); + responsePayload.put("fileName", fileName); + responsePayload.put("extensionId", extensionId); + responsePayload.put("requiresRestart", true); + if (cleanupResult.pendingCleanupArtifacts().isEmpty()) { + responsePayload.put("message", "Downloaded to plugins/MinePanel/extensions. Restart required."); + } else { + responsePayload.put("message", "Downloaded update, but old jar is currently in use. Restart required."); + } + responsePayload.put("removedArtifacts", cleanupResult.removedArtifacts()); + responsePayload.put("pendingCleanupArtifacts", cleanupResult.pendingCleanupArtifacts()); + return json(response, 200, responsePayload); + } catch (IllegalStateException exception) { + plugin.getLogger().warning("Could not replace extension asset " + fileName + ": " + exception.getMessage()); + return json(response, 409, Map.of("error", "extension_replace_failed", "details", exception.getMessage())); + } catch (Exception exception) { + plugin.getLogger().warning("Could not install extension asset " + fileName + ": " + exception.getMessage()); + return json(response, 500, Map.of("error", "extension_install_failed")); + } + } + + private ArtifactCleanupResult removeExistingExtensionArtifacts(Path extensionDirectory, String extensionId, String selectedFileName) { + List removed = new ArrayList<>(); + List pendingCleanup = new ArrayList<>(); + String normalizedSelected = selectedFileName == null ? "" : selectedFileName.trim().toLowerCase(Locale.ROOT); + + try (var files = Files.list(extensionDirectory)) { + for (Path candidate : files.toList()) { + if (!Files.isRegularFile(candidate)) { + continue; + } + + String candidateFileName = candidate.getFileName().toString(); + String loweredCandidate = candidateFileName.toLowerCase(Locale.ROOT); + if (!loweredCandidate.endsWith(".jar") || loweredCandidate.equals(normalizedSelected)) { + continue; + } + + String candidateExtensionId = normalizeExtensionKey(parseExtensionId(candidateFileName)); + if (candidateExtensionId.isBlank() || !candidateExtensionId.equals(extensionId)) { + continue; + } + + try { + Files.deleteIfExists(candidate); + removed.add(candidateFileName); + } catch (Exception exception) { + if (exception instanceof FileSystemException || exception instanceof AccessDeniedException) { + pendingCleanup.add(candidateFileName); + continue; + } + throw new IllegalStateException("Could not delete old extension artifact " + candidateFileName + ": " + exception.getMessage(), exception); + } + } + } catch (IOException exception) { + throw new IllegalStateException("Could not read extension directory", exception); + } + + return new ArtifactCleanupResult(List.copyOf(removed), List.copyOf(pendingCleanup)); + } + + private record ArtifactCleanupResult(List removedArtifacts, List pendingCleanupArtifacts) { + } + + private String handleExtensionReload(Request request, Response response) { + PanelUser user = requireUser(request, PanelPermission.MANAGE_EXTENSIONS); + + ExtensionManager.ReloadResult reloadResult; + try { + reloadResult = plugin.schedulerBridge().callGlobal( + () -> extensionManager.reloadNewFromDirectory(new ExtensionSparkRegistry()) + , 5, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return json(response, 500, Map.of("error", "reload_interrupted")); + } catch (ExecutionException | TimeoutException exception) { + plugin.getLogger().warning("Could not reload extensions: " + exception.getMessage()); + return json(response, 500, Map.of("error", "extension_reload_failed")); + } + + synchronized (restartRequiredExtensionIds) { + for (String loadedId : reloadResult.loadedExtensionIds()) { + restartRequiredExtensionIds.remove(normalizeExtensionKey(loadedId)); + } + } + + int loadedCount = reloadResult.loadedExtensionIds().size(); + panelLogger.log("PANEL", "EXTENSIONS", "Reload requested by " + user.username() + ": loaded " + loadedCount + " extension(s)"); + + String message = loadedCount == 0 + ? "No new extensions were loaded." + : "Loaded " + loadedCount + " extension(s) successfully."; + + return json(response, 200, Map.of( + "ok", true, + "loadedCount", loadedCount, + "loadedExtensionIds", reloadResult.loadedExtensionIds(), + "warningCount", reloadResult.warnings().size(), + "warnings", reloadResult.warnings(), + "message", message + )); + } + + private void downloadExtensionAsset(String downloadUrl, Path destination) throws Exception { + URI sourceUri = URI.create(downloadUrl); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(sourceUri) + .header("Accept", "application/octet-stream") + .header("User-Agent", "MinePanel") + .header("X-GitHub-Api-Version", "2022-11-28"); + + String token = resolveGitHubToken(); + if (!token.isBlank()) { + requestBuilder.header("Authorization", "Bearer " + token); + } + + HttpRequest request = requestBuilder.GET().build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + if (isRedirectStatus(response.statusCode())) { + Optional location = response.headers().firstValue("location"); + if (location.isEmpty() || location.get().isBlank()) { + throw new IllegalStateException("GitHub download redirect missing location"); + } + + URI redirectedUri = sourceUri.resolve(location.get().trim()); + HttpRequest redirectedRequest = HttpRequest.newBuilder(redirectedUri) + .header("Accept", "application/octet-stream") + .header("User-Agent", "MinePanel") + .GET() + .build(); + response = httpClient.send(redirectedRequest, HttpResponse.BodyHandlers.ofByteArray()); + } + + if (response.statusCode() >= 300) { + throw new IllegalStateException("GitHub download status " + response.statusCode()); + } + + Files.write(destination, response.body(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + } + + private boolean isRedirectStatus(int statusCode) { + return statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308; + } + + private boolean isValidExtensionJarFileName(String fileName) { + if (isBlank(fileName)) { + return false; + } + String trimmed = fileName.trim(); + String lowered = trimmed.toLowerCase(Locale.ROOT); + if (!lowered.endsWith(".jar") || !lowered.startsWith("minepanel-extension-")) { + return false; + } + return trimmed.matches("^[a-zA-Z0-9._-]+$"); + } + + private String normalizeReleaseChannel(String rawChannel) { + if (rawChannel == null || rawChannel.isBlank()) { + return "release"; + } + + String normalized = rawChannel.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "release", "prerelease" -> normalized; + default -> "release"; + }; + } + + private List> loadAvailableExtensionsFromGitHub(String channel) { + try { + lastGitHubCatalogError = ""; + JsonArray releases = requestGitHubReleases(); + JsonObject release = selectLatestReleaseByChannel(releases, channel); + if (release == null) { + return List.of(); + } + + String releaseLabel = firstNonBlank(stringValue(release, "name"), stringValue(release, "tag_name"), "latest"); + String releaseUrl = stringValue(release, "html_url"); + boolean prerelease = release.has("prerelease") && release.get("prerelease").getAsBoolean(); + + JsonArray assets = release.getAsJsonArray("assets"); + if (assets == null) { + return List.of(); + } + + List> rows = new ArrayList<>(); + for (JsonElement element : assets) { + if (element == null || !element.isJsonObject()) { + continue; + } + + JsonObject asset = element.getAsJsonObject(); + String fileName = stringValue(asset, "name"); + String downloadUrl = stringValue(asset, "browser_download_url"); + if (isBlank(fileName) || isBlank(downloadUrl)) { + continue; + } + + String lowered = fileName.toLowerCase(Locale.ROOT); + if (!lowered.endsWith(".jar") || !lowered.contains("minepanel-extension-")) { + continue; + } + + String extensionId = parseExtensionId(fileName); + if (isBlank(extensionId)) { + continue; + } + + rows.add(Map.of( + "id", fileName, + "name", readableExtensionName(fileName), + "description", "Asset from " + releaseLabel, + "release", releaseLabel, + "prerelease", prerelease, + "projectUrl", releaseUrl, + "downloadUrl", downloadUrl, + "extensionId", extensionId, + "version", releaseLabel + )); + } + + rows.sort(Comparator.comparing(row -> String.valueOf(row.getOrDefault("name", "")), String.CASE_INSENSITIVE_ORDER)); + return rows; + } catch (Exception exception) { + String message = exception.getMessage() == null ? "unknown_error" : exception.getMessage(); + lastGitHubCatalogError = "GitHub catalog unavailable: " + message; + plugin.getLogger().warning("Could not load extension catalog from GitHub: " + message); + return List.of(); + } + } + + private JsonArray requestGitHubReleases() { + long now = System.currentTimeMillis(); + if (now < githubReleaseCacheExpiresAtMillis && cachedGitHubReleases != null && !cachedGitHubReleases.isEmpty()) { + return cachedGitHubReleases; + } + + try { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(URI.create("https://api.github.com/repos/WinniePatGG/MinePanel/releases?per_page=20")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "MinePanel") + .header("X-GitHub-Api-Version", "2022-11-28"); + + String token = resolveGitHubToken(); + if (!token.isBlank()) { + requestBuilder.header("Authorization", "Bearer " + token); + } + + HttpRequest request = requestBuilder.GET().build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() >= 300) { + String errorMessage = "GitHub API status " + response.statusCode(); + try { + JsonObject body = gson.fromJson(response.body(), JsonObject.class); + if (body != null && body.has("message") && !body.get("message").isJsonNull()) { + errorMessage = body.get("message").getAsString(); + } + } catch (Exception ignored) { + // Keep fallback status text. + } + + if (response.statusCode() == 403 && cachedGitHubReleases != null && !cachedGitHubReleases.isEmpty()) { + lastGitHubCatalogError = "GitHub rate limit hit, showing cached extension data"; + githubReleaseCacheExpiresAtMillis = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5); + return cachedGitHubReleases; + } + + throw new IllegalStateException(errorMessage); + } + + JsonElement parsed = gson.fromJson(response.body(), JsonElement.class); + if (parsed == null || !parsed.isJsonArray()) { + cachedGitHubReleases = new JsonArray(); + githubReleaseCacheExpiresAtMillis = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(resolveGitHubCacheSeconds()); + return cachedGitHubReleases; + } + + cachedGitHubReleases = parsed.getAsJsonArray(); + githubReleaseCacheExpiresAtMillis = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(resolveGitHubCacheSeconds()); + return cachedGitHubReleases; + } catch (Exception exception) { + throw new IllegalStateException("Could not query GitHub releases", exception); + } + } + + private String resolveGitHubToken() { + String envToken = System.getenv("MINEPANEL_GITHUB_TOKEN"); + if (envToken != null && !envToken.isBlank()) { + return envToken.trim(); + } + + String configToken = plugin.getConfig().getString("integrations.github.token", ""); + return configToken == null ? "" : configToken.trim(); + } + + private int resolveGitHubCacheSeconds() { + int configured = plugin.getConfig().getInt("integrations.github.releaseCacheSeconds", 300); + return Math.max(30, Math.min(3600, configured)); + } + + private JsonObject selectLatestReleaseByChannel(JsonArray releases, String channel) { + if (releases == null || releases.isEmpty()) { + return null; + } + + boolean wantsPrerelease = "prerelease".equalsIgnoreCase(channel); + JsonObject selected = null; + Instant selectedPublishedAt = Instant.EPOCH; + + for (JsonElement element : releases) { + if (element == null || !element.isJsonObject()) { + continue; + } + + JsonObject release = element.getAsJsonObject(); + boolean draft = release.has("draft") && release.get("draft").getAsBoolean(); + if (draft) { + continue; + } + + boolean prerelease = release.has("prerelease") && release.get("prerelease").getAsBoolean(); + if (prerelease != wantsPrerelease) { + continue; + } + + Instant publishedAt; + try { + publishedAt = Instant.parse(stringValue(release, "published_at")); + } catch (Exception ignored) { + publishedAt = Instant.EPOCH; + } + + if (selected == null || publishedAt.isAfter(selectedPublishedAt)) { + selected = release; + selectedPublishedAt = publishedAt; + } + } + + return selected; + } + + private String parseExtensionId(String fileName) { + if (isBlank(fileName)) { + return ""; + } + + String lowered = fileName.trim().toLowerCase(Locale.ROOT); + lowered = lowered.replace(".jar", ""); + lowered = lowered.replaceFirst("^minepanel-extension-", ""); + int versionSplit = lowered.lastIndexOf("-alpha-"); + if (versionSplit > 0) { + lowered = lowered.substring(0, versionSplit); + } + return lowered; + } + + private String normalizeExtensionKey(String rawValue) { + if (rawValue == null) { + return ""; + } + + return rawValue + .trim() + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]", ""); + } + + private String readableExtensionName(String fileName) { + String id = parseExtensionId(fileName); + if (id.isBlank()) { + return fileName; + } + + String[] parts = id.split("-"); + StringBuilder builder = new StringBuilder(); + for (String part : parts) { + if (part.isBlank()) { + continue; + } + if (!builder.isEmpty()) { + builder.append(' '); + } + builder.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); + } + return builder.isEmpty() ? fileName : builder.toString(); + } + + private String extractVersionLabel(String rawValue) { + if (rawValue == null || rawValue.isBlank()) { + return ""; + } + + java.util.regex.Matcher alphaMatcher = java.util.regex.Pattern.compile("(?i)alpha-(\\d+)").matcher(rawValue); + String lastAlpha = ""; + while (alphaMatcher.find()) { + lastAlpha = "alpha-" + alphaMatcher.group(1); + } + if (!lastAlpha.isBlank()) { + return lastAlpha; + } + + java.util.regex.Matcher semverMatcher = java.util.regex.Pattern.compile("(?i)v?(\\d+(?:\\.\\d+)+)").matcher(rawValue); + String lastSemver = ""; + while (semverMatcher.find()) { + lastSemver = semverMatcher.group(1); + } + return lastSemver; + } + + private VersionToken parseVersionToken(String rawValue) { + String normalized = extractVersionLabel(rawValue); + if (normalized.isBlank()) { + return VersionToken.unknown(); + } + + java.util.regex.Matcher alphaMatcher = java.util.regex.Pattern.compile("(?i)alpha-(\\d+)").matcher(normalized); + if (alphaMatcher.matches()) { + try { + return VersionToken.alpha(Integer.parseInt(alphaMatcher.group(1))); + } catch (NumberFormatException ignored) { + return VersionToken.unknown(); + } + } + + java.util.regex.Matcher semverMatcher = java.util.regex.Pattern.compile("(?i)v?(\\d+(?:\\.\\d+)+)").matcher(normalized); + if (semverMatcher.matches()) { + String[] parts = semverMatcher.group(1).split("\\."); + List values = new ArrayList<>(); + for (String part : parts) { + try { + values.add(Integer.parseInt(part)); + } catch (NumberFormatException ignored) { + return VersionToken.unknown(); + } + } + return VersionToken.semver(values); + } + + return VersionToken.unknown(); + } + + private int compareVersionTokens(VersionToken installed, VersionToken latest) { + if (installed == null || latest == null || installed.kind() == VersionKind.UNKNOWN || latest.kind() == VersionKind.UNKNOWN) { + return Integer.MIN_VALUE; + } + + if (installed.kind() != latest.kind()) { + // Semantic versions are considered newer than alpha track. + if (installed.kind() == VersionKind.SEMVER && latest.kind() == VersionKind.ALPHA) { + return 1; + } + if (installed.kind() == VersionKind.ALPHA && latest.kind() == VersionKind.SEMVER) { + return -1; + } + return Integer.MIN_VALUE; + } + + if (installed.kind() == VersionKind.ALPHA) { + return Integer.compare(installed.alphaBuild(), latest.alphaBuild()); + } + + int max = Math.max(installed.semverParts().size(), latest.semverParts().size()); + for (int i = 0; i < max; i++) { + int left = i < installed.semverParts().size() ? installed.semverParts().get(i) : 0; + int right = i < latest.semverParts().size() ? latest.semverParts().get(i) : 0; + if (left != right) { + return Integer.compare(left, right); + } + } + return 0; + } + + private record ExtensionVersionInfo(String versionLabel, VersionToken versionToken) { + } + + private enum VersionKind { + UNKNOWN, + ALPHA, + SEMVER + } + + private record VersionToken(VersionKind kind, int alphaBuild, List semverParts) { + private static VersionToken unknown() { + return new VersionToken(VersionKind.UNKNOWN, -1, List.of()); + } + + private static VersionToken alpha(int build) { + return new VersionToken(VersionKind.ALPHA, build, List.of()); + } + + private static VersionToken semver(List parts) { + return new VersionToken(VersionKind.SEMVER, -1, parts == null ? List.of() : List.copyOf(parts)); + } + } + + private final class ExtensionSparkRegistry implements ExtensionWebRegistry { + + @Override + public void get(String path, PanelPermission permission, ExtensionRouteHandler handler) { + spark.Spark.get(path, (request, response) -> invokeExtensionHandler(request, response, permission, handler)); + } + + @Override + public void post(String path, PanelPermission permission, ExtensionRouteHandler handler) { + spark.Spark.post(path, (request, response) -> invokeExtensionHandler(request, response, permission, handler)); + } + + @Override + public String json(Response response, int status, Map payload) { + return WebPanelServer.this.json(response, status, payload); + } + + private Object invokeExtensionHandler(Request request, Response response, PanelPermission permission, ExtensionRouteHandler handler) throws Exception { + PanelUser user = requireUser(request, permission); + return handler.handle(request, response, user); + } + } + + private record LoginPayload(String username, String password) { + } + + private record BootstrapPayload(String token, String username, String password) { + } + + private record OAuthStartPayload(String mode) { + } + + private record CreateUserPayload(String username, String password, String role, List permissions) { + } + + private record UpdateRolePayload(String role) { + } + + private record UpdatePermissionsPayload(List permissions) { + } + + private record ConsolePayload(String message) { + } + + private record CommandDispatchResult(boolean dispatched, String response) { + } + + private record KickPayload(String reason) { + } + + private record TempBanPayload(String username, Integer durationMinutes, String reason) { + } + + private record BanPayload(String username, String uuid, String reason) { + } + + private record UnbanPayload(String username, String uuid) { + } + + private record DiscordWebhookPayload( + Boolean enabled, + String webhookUrl, + Boolean useEmbed, + String botName, + String messageTemplate, + String embedTitleTemplate, + Boolean logChat, + Boolean logCommands, + Boolean logAuth, + Boolean logAudit, + Boolean logSecurity, + Boolean logConsoleResponse, + Boolean logSystem + ) { + } + + private record PluginInstallPayload(String source, String projectId, String versionId) { + } + + private record MarketplaceDownload(String downloadUrl, String fileName) { + } + + private record OAuthUserProfile(String provider, String providerUserId, String displayName, String email, String avatarUrl) { + } + + private record PlayerActionResult(boolean success, String username, String error) { + } + + private record TempBanResult(boolean success, String username, long expiresAtMillis, String error) { + } + + private record OnlinePlayerSnapshot(UUID uuid, String username) { + } + + private record BanStatus(Long expiresAtMillis) { + } + + private record MetricSample(long timestampMillis, double tps, double memoryPercent, double memoryUsedMb, double cpuPercent) { + } + + private record JoinLeaveHeatmap( + List> joinCells, + List> leaveCells, + int maxJoin, + int maxLeave + ) { + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..7e66d72 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,25 @@ +web: + host: 127.0.0.1 + port: 8080 + sessionTtlMinutes: 120 + +security: + bootstrapTokenLength: 32 + +integrations: + oauth: + stateTtlMinutes: 10 + google: + enabled: false + clientId: "" + clientSecret: "" + redirectUri: "http://127.0.0.1:8080/api/oauth/google/callback" + discord: + enabled: false + clientId: "" + clientSecret: "" + redirectUri: "http://127.0.0.1:8080/api/oauth/discord/callback" + github: + token: "" + releaseCacheSeconds: 300 + diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..e4242b1 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,20 @@ +name: MinePanel +version: '${version}' +main: de.winniepat.minePanel.MinePanel +api-version: '1.21' +authors: [ WinniePatGG ] +website: https://winniepat.de +description: MinePanel with secure auth, user roles, and log history. +folia-supported: true + + +permissions: + minepanel.report: + description: Allows players to create reports via /report. + default: true + minepanel.ticket: + description: Allows players to create support tickets via /ticket. + default: true + minepanel.chatfilter.bypass: + description: Bypasses MinePanel bad-word chat filter auto moderation. + default: op diff --git a/src/main/resources/web/dashboard-account.html b/src/main/resources/web/dashboard-account.html new file mode 100644 index 0000000..926ba36 --- /dev/null +++ b/src/main/resources/web/dashboard-account.html @@ -0,0 +1,240 @@ + + + + + + MinePanel Dashboard - Account + + + + + +
+ + +
+

Account Settings

+
+

Linked OAuth Accounts

+

Link Google or Discord for one-click login on the login page.

+

+
+
+
+
+ + + + + diff --git a/src/main/resources/web/dashboard-announcements.html b/src/main/resources/web/dashboard-announcements.html new file mode 100644 index 0000000..27e0359 --- /dev/null +++ b/src/main/resources/web/dashboard-announcements.html @@ -0,0 +1,444 @@ + + + + + + MinePanel Dashboard - Announcements + + + + + +
+ + +
+

Announcements

+

Broadcast rotating server announcements to all online players.

+ +
+

Status

+
+
+
State
+
Disabled
+
+
+
Interval
+
300s
+
+
+
Next Broadcast
+
-
+
+
+
Messages
+
0
+
+
+
+ +
+

Configuration

+ + + +
+ + + +
+

+
+ +
+

Create Message

+ +

+ Add your own prefix directly in the message text (for example [Network] or <gold>[MinePanel]</gold>).
+ Placeholders: %player%, %time%, %date%, %datetime%, %online%, %max_players%, %world%, %server%, %tps%. +

+
+ +
+
+ +
+

Messages

+
+
+
+
+ + + + + diff --git a/src/main/resources/web/dashboard-bans.html b/src/main/resources/web/dashboard-bans.html new file mode 100644 index 0000000..8ca451f --- /dev/null +++ b/src/main/resources/web/dashboard-bans.html @@ -0,0 +1,191 @@ + + + + + + MinePanel Dashboard - Bans + + + + +
+ + +
+

Bans

+

Current bans: 0

+ +
+

Ban List

+
+ + + + + + + + + + +
PlayerStatusExpiresAction
+
+

+
+
+
+ + + + + diff --git a/src/main/resources/web/dashboard-console.html b/src/main/resources/web/dashboard-console.html new file mode 100644 index 0000000..f4da9cb --- /dev/null +++ b/src/main/resources/web/dashboard-console.html @@ -0,0 +1,181 @@ + + + + + + MinePanel Console + + + + +
+ + +
+

Console

+
+ +
+ +
+

Panel Logs + Chat + Commands

+

+            
+ + +
+
+
+
+ + + + + + + diff --git a/src/main/resources/web/dashboard-discord-webhook.html b/src/main/resources/web/dashboard-discord-webhook.html new file mode 100644 index 0000000..a5e326e --- /dev/null +++ b/src/main/resources/web/dashboard-discord-webhook.html @@ -0,0 +1,216 @@ + + + + + + MinePanel Dashboard - Discord Webhook + + + + +
+ + +
+

Discord Webhook

+

Configure what MinePanel sends to your Discord channel.

+ +
+

Webhook Settings

+
+ + +
Webhook URL
+ + +
Bot Name
+ + + + +
Message Template
+ + +
Embed Title Template
+ + +
Event Filters
+
+ + + + + + + +
+ + +

+
+
+ +
+

Template Variables

+

Use {timestamp}, {kind}, {source}, and {message} in your templates.

+
+
+
+ + + + + diff --git a/src/main/resources/web/dashboard-extension-config.html b/src/main/resources/web/dashboard-extension-config.html new file mode 100644 index 0000000..cba7b89 --- /dev/null +++ b/src/main/resources/web/dashboard-extension-config.html @@ -0,0 +1,543 @@ + + + + + + MinePanel Dashboard - Extension Config + + + + + +
+ + +
+

Extension Config

+

Configure extension-specific settings from the panel using form fields.

+ +
+
+
+

Installed Extensions

+
+
+
+

Settings

+
+
+ + + +
+

+
+
+
+
+
+ + + + + diff --git a/src/main/resources/web/dashboard-extensions.html b/src/main/resources/web/dashboard-extensions.html new file mode 100644 index 0000000..4f303bc --- /dev/null +++ b/src/main/resources/web/dashboard-extensions.html @@ -0,0 +1,417 @@ + + + + + + MinePanel Dashboard - Extensions + + + + + +
+ + +
+

Extensions

+

Shows loaded extensions and extension downloads from the latest GitHub release/pre-release.

+ + + +
+ + + +
+ +
+

Installed Extensions (0)

+ + + + + + + + + + +
IDNameSourceStatus
+
+ +
+

Available Extensions on GitHub (0)

+ + + + + + + + + + +
NameDescriptionReleaseDownload
+
+
+
+ + + + + diff --git a/src/main/resources/web/dashboard-maintenance.html b/src/main/resources/web/dashboard-maintenance.html new file mode 100644 index 0000000..0cacaac --- /dev/null +++ b/src/main/resources/web/dashboard-maintenance.html @@ -0,0 +1,349 @@ + + + + + + MinePanel Dashboard - Maintenance + + + + + +
+ + +
+

Maintenance

+

Enable maintenance mode, block non-staff joins, and remove non-staff players currently online.

+ +
+

Status

+
+
+
State
+
Unknown
+
+
+
Affected Players
+
0
+
+
+
Last Changed
+
-
+
+
+
Bypass Permission
+
minepanel.maintenance.bypass
+
+
+

Operators and players with bypass permission can still join.

+
+ +
+

Controls

+ + + + +
+ + + + + +
+
+
+
+
+ + + + + diff --git a/src/main/resources/web/dashboard-overview.html b/src/main/resources/web/dashboard-overview.html new file mode 100644 index 0000000..f1a45f1 --- /dev/null +++ b/src/main/resources/web/dashboard-overview.html @@ -0,0 +1,523 @@ + + + + + + MinePanel Dashboard - Overview + + + + +
+ + +
+

Overview

+
+
+
TPS Unavailable
+
-
+
+
+
Memory Usage
+
0 MB / 0 MB
+
+
+
CPU Usage
+
-
+
+
+ +
+

TPS Over Time (Last Hour)

+
+ +
+
+ +
+
+

Memory Over Time

+
+ +
+
+
+

CPU Over Time

+
+ +
+
+
+ +
+
+

Join Heatmap (Day x Hour)

+
+ +
+
+
+

Leave Heatmap (Day x Hour)

+
+ +
+
+
+
+
+ + + + + diff --git a/src/main/resources/web/dashboard-players.html b/src/main/resources/web/dashboard-players.html new file mode 100644 index 0000000..8e6acec --- /dev/null +++ b/src/main/resources/web/dashboard-players.html @@ -0,0 +1,710 @@ + + + + + + MinePanel Dashboard - Players + + + + +
+ + +
+

Players

+

All players that ever joined: 0

+ +
+
+
+

Player List

+ +
+
    +
    + +
    +

    Player Profile

    +
    Select a player from the list.
    + +
    +
    +
    +
    + + + + diff --git a/src/main/resources/web/dashboard-plugins.html b/src/main/resources/web/dashboard-plugins.html new file mode 100644 index 0000000..1a5f34c --- /dev/null +++ b/src/main/resources/web/dashboard-plugins.html @@ -0,0 +1,368 @@ + + + + + + MinePanel Dashboard - Plugins + + + + +
    + + +
    +

    Plugins

    +

    Installed server plugins: 0

    + +
    + + +
    + +
    +

    Installed Plugins

    +
    + + + + + + + + + + + +
    NameVersionEnabledMain ClassAuthors
    +
    +
    + + +
    +
    + + + + + diff --git a/src/main/resources/web/dashboard-reports.html b/src/main/resources/web/dashboard-reports.html new file mode 100644 index 0000000..6cd2e38 --- /dev/null +++ b/src/main/resources/web/dashboard-reports.html @@ -0,0 +1,205 @@ + + + + + + MinePanel Dashboard - Reports + + + + +
    + + +
    +

    Reports

    +
    + + +
    + +
    +

    Player Reports

    + + + + + + + + + + + + + +
    IDReporterSuspectReasonStatusCreatedActions
    +
    +
    +
    + + + + + diff --git a/src/main/resources/web/dashboard-resources.html b/src/main/resources/web/dashboard-resources.html new file mode 100644 index 0000000..034de82 --- /dev/null +++ b/src/main/resources/web/dashboard-resources.html @@ -0,0 +1,172 @@ + + + + + + MinePanel Dashboard - Resources + + + + +
    + + +
    +

    Resources

    +
    +
    +
    CPU Load (System)
    +
    0%
    +
    +
    +
    CPU Load (MinePanel Process)
    +
    0%
    +
    +
    +
    Memory Used
    +
    0 MB
    +
    +
    +
    Disk Used
    +
    0 GB
    +
    +
    + +
    +

    Server Runtime

    +
    
    +        
    +
    +
    + + + + + + diff --git a/src/main/resources/web/dashboard-themes.html b/src/main/resources/web/dashboard-themes.html new file mode 100644 index 0000000..acf7be6 --- /dev/null +++ b/src/main/resources/web/dashboard-themes.html @@ -0,0 +1,583 @@ + + + + + + MinePanel Dashboard - Themes + + + + +
    + + +
    +

    Themes

    +

    Pick a preset or customize panel colors.

    + +
    +

    Presets

    +
    + + +
    +
    + +
    +

    Custom Colors

    +
    +
    + + +
    +

    +
    +
    +
    + + + + + diff --git a/src/main/resources/web/dashboard-tickets.html b/src/main/resources/web/dashboard-tickets.html new file mode 100644 index 0000000..723a670 --- /dev/null +++ b/src/main/resources/web/dashboard-tickets.html @@ -0,0 +1,200 @@ + + + + + + MinePanel Dashboard - Tickets + + + + +
    + + +
    +

    Tickets

    +
    + + +
    + +
    +

    Player Tickets

    + + + + + + + + + + + + + + +
    IDPlayerCategoryDescriptionStatusCreatedHandled ByActions
    +
    +
    +
    + + + + + diff --git a/src/main/resources/web/dashboard-users.html b/src/main/resources/web/dashboard-users.html new file mode 100644 index 0000000..f5e03d4 --- /dev/null +++ b/src/main/resources/web/dashboard-users.html @@ -0,0 +1,624 @@ + + + + + + MinePanel Dashboard - Users + + + + + +
    + + +
    +

    Users

    +
    +

    User Management

    +
    + +
    + +
    +
    + + + +
    IDUsernameRoleSave RolePermissionsDelete
    +
    + +
    + + +

    +
    +
    +
    + + + + + diff --git a/src/main/resources/web/dashboard-whitelist.html b/src/main/resources/web/dashboard-whitelist.html new file mode 100644 index 0000000..4ede40c --- /dev/null +++ b/src/main/resources/web/dashboard-whitelist.html @@ -0,0 +1,305 @@ + + + + + + MinePanel Dashboard - Whitelist + + + + +
    + + +
    +

    Whitelist

    +

    View, add, and remove whitelisted players.

    + +
    +

    Whitelist Status

    +
    + Status: Unknown + + +
    +
    + +
    +

    Add Player

    +
    + + + +
    + +
    + +
    +

    Whitelisted Players (0)

    + + + + + + + + + + +
    UsernameUUIDOnlineActions
    +
    +
    +
    + + + + + diff --git a/src/main/resources/web/dashboard-world-backups.html b/src/main/resources/web/dashboard-world-backups.html new file mode 100644 index 0000000..1e91bc0 --- /dev/null +++ b/src/main/resources/web/dashboard-world-backups.html @@ -0,0 +1,289 @@ + + + + + + MinePanel Dashboard - World Backups + + + + +
    + + +
    +

    World Backups

    +

    Create separate backups for Overworld, Nether and End. Backups are saved to plugins/MinePanel/backups.

    + +
    +

    Create Backup

    +
    + + + +
    +

    +
    + +
    +

    Overworld Backups

    + + + + + + + + + + + +
    NameFileCreatedSizeActions
    +
    + +
    +

    Nether Backups

    + + + + + + + + + + + +
    NameFileCreatedSizeActions
    +
    + +
    +

    End Backups

    + + + + + + + + + + + +
    NameFileCreatedSizeActions
    +
    +
    +
    + + + + + diff --git a/src/main/resources/web/login.html b/src/main/resources/web/login.html new file mode 100644 index 0000000..b4777db --- /dev/null +++ b/src/main/resources/web/login.html @@ -0,0 +1,115 @@ + + + + + + MinePanel Login + + + + +
    +
    +

    MinePanel

    +

    Sign in to access logs and admin tools.

    +
    +
    +
    Username
    + +
    Password
    + + +
    +
    + + + + diff --git a/src/main/resources/web/panel.css b/src/main/resources/web/panel.css new file mode 100644 index 0000000..0f2bc91 --- /dev/null +++ b/src/main/resources/web/panel.css @@ -0,0 +1,968 @@ +:root { + --bg: #0b1220; + --surface: #111a2e; + --surface-2: #16233d; + --border: #24324f; + --text: #e5ebf8; + --text-muted: #99a8c6; + --accent: #4f8cff; + --accent-strong: #3a75e8; + --danger-bg: #3d1d29; + --danger-text: #ffb7c7; + --log-bg: #0a101d; + --sidebar-bg: #060b16; + --sidebar-border: #1b2942; + --sidebar-meta-bg: #0d1528; + --sidebar-meta-border: #1f3050; + --sidebar-toggle-text: #7f93b8; + --sidebar-toggle-icon: #8fa6d0; + --sidebar-link-text: #c7d7f7; + --sidebar-link-bg: #0f1930; + --sidebar-link-border: #263a5d; + --sidebar-link-hover-bg: #162642; + --button-bg: #4f8cff; + --button-border: #3a75e8; + --button-hover-bg: #5a95ff; + --button-secondary-bg: #21314d; + --button-secondary-border: #344769; + --input-bg: #0f1930; + --focus-ring: #4f8cff; + --table-bg: #0e172b; + --table-head-bg: #13203b; + --table-row-border: #1f2d49; + --table-row-hover-bg: #111d35; + --table-head-text: #d7e2f8; + --table-cell-text: #c7d4ee; + --log-border: #213457; + --log-text: #d6e0f8; + --chart-bg: #0b1427; + --chart-border: #213457; + --chart-grid: #7896d2; + --chart-label: #8ea6d2; + --player-box-bg: #0f1930; + --player-box-border: #23365a; + --player-box-hover-bg: #162642; + --player-box-active-bg: #1b2c4a; + --player-box-active-border: #3f73cf; + --player-muted-bg: #101a2f; + --player-muted-border: #2a3f63; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + background: radial-gradient(1200px circle at 0 0, #15213a 0%, var(--bg) 55%); + color: var(--text); + font-family: "Inter", "Segoe UI", Arial, sans-serif; + line-height: 1.4; + overflow: hidden; +} + +h1, +h2 { + margin: 0 0 10px; + font-weight: 700; +} + +h1 { + font-size: 28px; +} + +h2 { + font-size: 18px; + color: #cfd9ee; +} + +p { + color: var(--text-muted); +} + +.page-center { + max-width: 540px; + margin: 52px auto; + padding: 0 14px; +} + +.page-wide { + max-width: 1250px; + margin: 26px auto; + padding: 0 16px 24px; +} + +.layout { + height: 100vh; + display: grid; + grid-template-columns: 240px 1fr; + overflow: hidden; +} + +.sidebar { + background: var(--sidebar-bg); + border-right: 1px solid var(--sidebar-border); + padding: 18px 14px; + display: flex; + flex-direction: column; + gap: 12px; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; +} + +.sidebar-brand { + font-size: 30px; + font-weight: 800; + color: #f1f5ff; + margin-bottom: 6px; +} + +.sidebar-meta { + color: var(--text-muted); + font-size: 13px; + border: 1px solid var(--sidebar-meta-border); + border-radius: 8px; + background: var(--sidebar-meta-bg); + padding: 9px 10px; +} + +.side-nav { + display: flex; + flex-direction: column; + gap: 6px; +} + +.side-category-toggle { + margin-top: 6px; + margin-bottom: 2px; + color: var(--sidebar-toggle-text); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 4px 2px; + border: 0; + background: transparent; + text-align: left; + width: 100%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; +} + +.side-category-toggle::after { + content: "▾"; + font-size: 11px; + color: var(--sidebar-toggle-icon); + transform: rotate(-90deg); + transition: transform 0.2s ease; +} + +.side-category-toggle.expanded::after { + transform: rotate(0deg); +} + +.side-category-items { + display: grid; + gap: 6px; + overflow: hidden; + max-height: 0; + opacity: 0; + transition: max-height 0.22s ease, opacity 0.22s ease; +} + +.side-category-items.expanded { + max-height: 420px; + opacity: 1; +} + +.side-link { + text-decoration: none; + color: var(--sidebar-link-text); + background: var(--sidebar-link-bg); + border: 1px solid var(--sidebar-link-border); + border-radius: 8px; + padding: 10px 11px; + font-weight: 600; + font-size: 14px; +} + +.side-link:hover { + background: var(--sidebar-link-hover-bg); +} + +.side-link.active { + background: var(--accent); + border-color: var(--accent-strong); + color: #fff; +} + +.sidebar-logout { + margin-top: auto; +} + +.content-area { + padding: 26px 22px 24px; + height: 100vh; + overflow-y: auto; +} + +.stats-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.overview-top-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.overview-bottom-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.stat-card { + padding: 14px; +} + +.stat-label { + color: var(--text-muted); + font-size: 13px; +} + +.stat-chip { + display: inline-block; + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid #294166; + background: #101b31; + color: #c8dafb; + font-size: 12px; +} + +.stat-value { + margin-top: 4px; + font-size: 28px; + font-weight: 700; + color: #f1f5ff; +} + +.metric-status { + display: inline-block; + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.metric-good { + background: rgba(46, 204, 113, 0.2); + border: 1px solid rgba(46, 204, 113, 0.45); + color: #8af0b4; +} + +.metric-medium { + background: rgba(241, 196, 15, 0.2); + border: 1px solid rgba(241, 196, 15, 0.45); + color: #ffe08b; +} + +.metric-bad { + background: rgba(231, 76, 60, 0.2); + border: 1px solid rgba(231, 76, 60, 0.45); + color: #ff9f95; +} + +.metric-unknown { + background: rgba(127, 140, 170, 0.2); + border: 1px solid rgba(127, 140, 170, 0.45); + color: #c7d1ea; +} + +.chart-wrapper { + background: var(--chart-bg); + border: 1px solid var(--chart-border); + border-radius: 8px; + padding: 8px; + height: 260px; +} + +.chart-wrapper.chart-tall { + height: 340px; +} + +.chart-wrapper canvas { + width: 100%; + height: 100%; + display: block; +} + +.card { + background: linear-gradient(180deg, var(--surface) 0%, var(--surface-2) 100%); + border: 1px solid var(--border); + border-radius: 10px; + padding: 18px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.26); +} + +.stack > * + * { + margin-top: 10px; +} + +.label { + font-size: 13px; + color: var(--text-muted); +} + +input, +select, +button { + width: 100%; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text); + padding: 10px 11px; + font-size: 14px; +} + +input:focus, +select:focus, +button:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--focus-ring); +} + +button { + background: var(--button-bg); + border-color: var(--button-border); + cursor: pointer; + font-weight: 600; +} + +button:hover { + background: var(--button-hover-bg); +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +button.secondary { + background: var(--button-secondary-bg); + border-color: var(--button-secondary-border); +} + +.error { + background: var(--danger-bg); + border: 1px solid #6a3040; + color: var(--danger-text); + border-radius: 8px; + padding: 8px 10px; + min-height: 16px; +} + +.error:empty { + display: none; +} + +.toolbar { + display: flex; + gap: 8px; + margin: 14px 0; +} + +.tabs { + display: flex; + gap: 8px; + margin: 12px 0 14px; +} + +.tab-link { + display: inline-block; + padding: 9px 14px; + border-radius: 8px; + border: 1px solid #344769; + background: #1a2842; + color: #cfe0ff; + text-decoration: none; + font-size: 14px; + font-weight: 600; +} + +.tabs .tab-link { + width: auto; +} + +.tab-link:hover { + background: #223558; +} + +.tab-link.active { + background: var(--accent); + border-color: var(--accent-strong); + color: #ffffff; +} + +.toolbar button { + width: auto; + min-width: 140px; +} + +.row { + display: grid; + gap: 14px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.card-head h2 { + margin: 0; +} + +.log-file-select { + width: 240px; +} + +.log-rectangle { + background: var(--log-bg); + color: var(--log-text); + margin: 0; + border: 1px solid var(--log-border); + border-radius: 8px; + padding: 12px; + white-space: pre-wrap; + height: 380px; + overflow-y: auto; + overflow-x: hidden; + font-size: 13px; + line-height: 1.35; +} + +.log-rectangle.short { + height: 260px; +} + +.console-send-row { + margin-top: 10px; + display: grid; + gap: 8px; + grid-template-columns: 1fr auto; +} + +.console-send-row button { + width: auto; + min-width: 120px; +} + +.inline-form { + display: grid; + gap: 8px; + grid-template-columns: 1.4fr 1.4fr 1fr auto; + margin-bottom: 10px; +} + +.inline-form button { + width: auto; + min-width: 140px; +} + +.table-wrap { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.spaced-top { + margin-top: 14px; +} + +.player-list { + list-style: none; + margin: 10px 0 0; + padding: 0; + display: grid; + gap: 8px; + max-height: 260px; + overflow-y: auto; +} + +.player-item { + display: flex; + align-items: center; + gap: 10px; + background: #101a2f; + border: 1px solid #24395e; + border-radius: 8px; + padding: 8px 10px; +} + +.player-avatar { + width: 32px; + height: 32px; + border-radius: 4px; + border: 1px solid #2f4f82; + image-rendering: pixelated; +} + +.player-name { + color: #e0e8fb; + font-size: 14px; + font-weight: 600; +} + +.player-empty { + color: var(--text-muted); + background: var(--player-muted-bg); + border: 1px dashed var(--player-muted-border); + border-radius: 8px; + padding: 10px; +} + +.player-actions-toolbar { + display: grid; + gap: 8px; + grid-template-columns: 1.4fr 1fr 1.3fr auto; +} + +.players-layout { + display: grid; + gap: 12px; + grid-template-columns: 320px minmax(0, 1fr); + align-items: stretch; +} + +.players-layout > .card { + height: 100%; +} + +.profile-card { + min-height: 560px; +} + +.player-search { + max-width: 220px; +} + +.player-directory { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; + max-height: 560px; + overflow-y: auto; +} + +.directory-item { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--player-box-border); + border-radius: 8px; + background: var(--player-box-bg); + padding: 8px 10px; + cursor: pointer; +} + +.directory-item:hover { + background: var(--player-box-hover-bg); +} + +.directory-item.active { + border-color: var(--player-box-active-border); + background: var(--player-box-active-bg); +} + +.directory-name { + color: #d8e4fb; + font-weight: 600; + font-size: 14px; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 999px; + display: inline-block; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.status-dot.online { + background: #27c967; +} + +.status-dot.offline { + background: #d64545; +} + +.profile-pane { + display: grid; + gap: 12px; + height: 100%; + align-content: start; +} + +.profile-header { + display: flex; + align-items: center; + gap: 12px; +} + +.profile-avatar { + width: 56px; + height: 56px; + border-radius: 8px; + border: 1px solid #35588f; + image-rendering: pixelated; +} + +.profile-name-row { + display: flex; + align-items: center; + gap: 8px; +} + +.profile-name { + font-size: 20px; + font-weight: 700; + color: #e7efff; +} + +.profile-status { + color: #9db0d2; + font-size: 13px; +} + +.profile-uuid { + color: #95a9cd; + font-size: 12px; + word-break: break-all; +} + +.profile-grid { + display: grid; + gap: 10px; + grid-template-columns: 1fr 1fr; +} + +.profile-field { + background: var(--player-box-bg); + border: 1px solid var(--player-box-border); + border-radius: 8px; + padding: 9px 10px; + display: grid; + gap: 3px; +} + +.profile-field span { + color: #9cb0d1; + font-size: 12px; +} + +.profile-field strong { + color: #e3ecff; + font-size: 14px; +} + +.player-actions-toolbar button { + width: auto; + min-width: 150px; +} + +.player-inline { + display: flex; + align-items: center; + gap: 8px; +} + +.player-actions { + display: flex; + gap: 8px; +} + +.player-actions button { + width: auto; + min-width: 110px; +} + +.danger-button { + background: #b33046; + border-color: #8f2436; +} + +.danger-button:hover { + background: #c93b52; +} + +.action-status { + margin: 10px 0 0; + font-size: 13px; + min-height: 18px; +} + +.integration-form { + display: grid; + gap: 10px; +} + +.checkbox-line { + display: flex; + align-items: center; + gap: 8px; +} + +.checkbox-line input[type="checkbox"] { + width: 16px; + height: 16px; +} + +.toggle-grid { + display: grid; + gap: 8px; + grid-template-columns: 1fr 1fr; +} + +.template-help { + margin: 0; +} + +.marketplace-search-row { + display: grid; + gap: 8px; + grid-template-columns: 200px 1fr auto; +} + +.theme-preset-row { + display: grid; + gap: 8px; + grid-template-columns: 1fr auto; +} + +.theme-preset-row button { + width: auto; + min-width: 150px; +} + +.theme-grid { + margin-top: 6px; + display: grid; + gap: 10px; +} + +.theme-group { + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + background: var(--surface); +} + +.theme-group-title { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.theme-group-grid { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.theme-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid #23365a; + border-radius: 8px; + padding: 8px 10px; + background: #0f1930; + color: #c6d6f4; + font-size: 13px; +} + +.theme-field input[type="color"] { + width: 50px; + height: 30px; + padding: 0; + border: 0; + background: transparent; +} + +.theme-actions { + margin-top: 10px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.theme-actions button { + width: auto; + min-width: 170px; +} + +.marketplace-search-row button { + width: auto; + min-width: 120px; +} + +.success-text { + color: #8addba; +} + +.error-text { + color: #ff9faf; +} + +table { + width: 100%; + border-collapse: collapse; + background: var(--table-bg); +} + +th, +td { + border-bottom: 1px solid var(--table-row-border); + padding: 8px; + text-align: left; +} + +th { + background: var(--table-head-bg); + color: var(--table-head-text); + font-size: 13px; +} + +td { + color: var(--table-cell-text); +} + +tbody tr:hover { + background: var(--table-row-hover-bg); +} + +@media (max-width: 960px) { + body { + overflow: auto; + } + + .layout { + height: auto; + grid-template-columns: 1fr; + overflow: visible; + } + + .sidebar { + position: static; + top: auto; + height: auto; + border-right: 0; + border-bottom: 1px solid #1b2942; + } + + .content-area { + height: auto; + overflow: visible; + } + + .row { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: 1fr 1fr; + } + + .overview-top-grid { + grid-template-columns: 1fr; + } + + .overview-bottom-grid { + grid-template-columns: 1fr; + } + + .inline-form { + grid-template-columns: 1fr; + } + + .player-actions-toolbar { + grid-template-columns: 1fr; + } + + .player-actions { + flex-wrap: wrap; + } + + .players-layout { + grid-template-columns: 1fr; + } + + .profile-grid { + grid-template-columns: 1fr; + } + + .marketplace-search-row { + grid-template-columns: 1fr; + } + + .theme-preset-row { + grid-template-columns: 1fr; + } + + .theme-grid { + grid-template-columns: 1fr; + } + + .theme-group-grid { + grid-template-columns: 1fr; + } + + .console-send-row { + grid-template-columns: 1fr; + } + + .toolbar { + flex-wrap: wrap; + } + + .toolbar button { + width: 100%; + } + + .card-head { + flex-direction: column; + align-items: stretch; + } + + .log-file-select { + width: 100%; + } +} + diff --git a/src/main/resources/web/setup.html b/src/main/resources/web/setup.html new file mode 100644 index 0000000..7a867b1 --- /dev/null +++ b/src/main/resources/web/setup.html @@ -0,0 +1,48 @@ + + + + + + Panel Setup + + + + +
    +
    +

    First Launch Setup

    +

    Use the bootstrap token printed in the server console to create the owner account.

    +
    +
    Bootstrap Token
    + +
    Owner Username
    + +
    Owner Password
    + + +
    +
    + + + + diff --git a/src/main/resources/web/theme.js b/src/main/resources/web/theme.js new file mode 100644 index 0000000..0ad2554 --- /dev/null +++ b/src/main/resources/web/theme.js @@ -0,0 +1,486 @@ +(function () { + const STORAGE_KEY = 'minepanel.customTheme'; + const CURRENT_USER_CACHE_KEY = 'minepanel.me.cache'; + const CURRENT_USER_CACHE_TTL_MS = 5000; + const EXT_NAV_CACHE_KEY = 'minepanel.extNav.cache'; + const EXT_NAV_CACHE_TTL_MS = 30000; + + function applyTheme(theme) { + if (!theme || typeof theme !== 'object') { + return; + } + + const root = document.documentElement; + Object.entries(theme).forEach(([name, value]) => { + if (!name.startsWith('--')) { + return; + } + if (typeof value !== 'string' || value.trim() === '') { + return; + } + root.style.setProperty(name, value); + }); + } + + function loadTheme() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return; + } + const parsed = JSON.parse(raw); + applyTheme(parsed); + } catch (ignored) { + // Ignore malformed localStorage data. + } + } + + window.MinePanelTheme = { + storageKey: STORAGE_KEY, + applyTheme, + loadTheme + }; + + async function loadExtensionNavigationTabs() { + const sideNav = document.querySelector('.side-nav'); + if (!sideNav) { + return; + } + + const extensionManagedPaths = new Set([ + '/dashboard/world-backups', + '/dashboard/maintenance', + '/dashboard/whitelist', + '/dashboard/announcements', + '/dashboard/reports', + '/dashboard/tickets' + ]); + + ensurePanelExtensionsLink(sideNav); + ensurePanelAccountLink(sideNav); + ensurePanelExtensionConfigLink(sideNav); + enforceServerCategoryOrder(sideNav); + + const cachedTabs = readCachedExtensionTabs(); + if (cachedTabs.length > 0) { + applyExtensionTabs(sideNav, cachedTabs, extensionManagedPaths); + ensurePanelAccountLink(sideNav); + enforceServerCategoryOrder(sideNav); + await applySidebarPermissionVisibility(sideNav); + } + + try { + const response = await fetch('/api/extensions/navigation', { credentials: 'same-origin', cache: 'no-store' }); + if (!response.ok) { + return; + } + + const payload = await response.json(); + const tabs = Array.isArray(payload.tabs) ? payload.tabs : []; + applyExtensionTabs(sideNav, tabs, extensionManagedPaths); + writeCachedExtensionTabs(tabs); + + if (tabs.length === 0) { + ensurePanelAccountLink(sideNav); + enforceServerCategoryOrder(sideNav); + await applySidebarPermissionVisibility(sideNav); + return; + } + + ensurePanelAccountLink(sideNav); + enforceServerCategoryOrder(sideNav); + await applySidebarPermissionVisibility(sideNav); + } catch (ignored) { + // Ignore extension tab loading issues on login/setup pages. + ensurePanelAccountLink(sideNav); + enforceServerCategoryOrder(sideNav); + await applySidebarPermissionVisibility(sideNav); + } + } + + function applyExtensionTabs(sideNav, tabs, extensionManagedPaths) { + const sanitizedTabs = (Array.isArray(tabs) ? tabs : []) + .filter(tab => tab && typeof tab.path === 'string' && typeof tab.label === 'string' && typeof tab.category === 'string') + .map(tab => ({ + path: tab.path.trim(), + label: tab.label.trim(), + category: tab.category.trim().toLowerCase() + })) + .filter(tab => tab.path && tab.category); + + const runtimeTabPaths = new Set(sanitizedTabs.map(tab => tab.path)); + + // Remove extension links that are not part of the currently loaded extension set. + sideNav.querySelectorAll('a.side-link').forEach(link => { + const href = (link.getAttribute('href') || '').trim(); + if (!extensionManagedPaths.has(href)) { + return; + } + if (!runtimeTabPaths.has(href)) { + link.remove(); + } + }); + + for (const tab of sanitizedTabs) { + const container = ensureCategoryContainer(sideNav, tab.category); + if (!container) { + continue; + } + + let link = sideNav.querySelector(`a.side-link[href="${cssEscape(tab.path)}"]`); + if (!link) { + link = document.createElement('a'); + link.className = 'side-link'; + link.href = tab.path; + container.appendChild(link); + } else if (link.parentElement !== container) { + container.appendChild(link); + } + + link.textContent = tab.label || tab.path; + link.classList.toggle('active', window.location.pathname === tab.path); + } + } + + function readCachedExtensionTabs() { + const now = Date.now(); + try { + const cachedRaw = sessionStorage.getItem(EXT_NAV_CACHE_KEY); + if (!cachedRaw) { + return []; + } + + const cached = JSON.parse(cachedRaw); + if (!cached || typeof cached !== 'object' || Number(cached.expiresAt || 0) <= now) { + return []; + } + + return Array.isArray(cached.tabs) ? cached.tabs : []; + } catch (ignored) { + return []; + } + } + + function writeCachedExtensionTabs(tabs) { + try { + sessionStorage.setItem(EXT_NAV_CACHE_KEY, JSON.stringify({ + tabs: Array.isArray(tabs) ? tabs : [], + expiresAt: Date.now() + EXT_NAV_CACHE_TTL_MS + })); + } catch (ignored) { + // Ignore unavailable session storage. + } + } + + function cssEscape(value) { + if (typeof window.CSS !== 'undefined' && typeof window.CSS.escape === 'function') { + return window.CSS.escape(value); + } + return String(value).replace(/"/g, '\\"'); + } + + async function applySidebarPermissionVisibility(sideNav) { + const me = await fetchCurrentUser(); + if (!me) { + return; + } + + if (!Array.isArray(me.permissions)) { + return; + } + + if (me.isOwner === true) { + sideNav.querySelectorAll('a.side-link').forEach(link => { + link.style.display = ''; + }); + return; + } + + const permissionSet = new Set(me.permissions); + const linkPermissions = new Map([ + ['/dashboard/overview', 'VIEW_OVERVIEW'], + ['/console', 'VIEW_CONSOLE'], + ['/dashboard/console', 'VIEW_CONSOLE'], + ['/dashboard/resources', 'VIEW_RESOURCES'], + ['/dashboard/players', 'VIEW_PLAYERS'], + ['/dashboard/bans', 'VIEW_BANS'], + ['/dashboard/plugins', 'VIEW_PLUGINS'], + ['/dashboard/users', 'VIEW_USERS'], + ['/dashboard/discord-webhook', 'VIEW_DISCORD_WEBHOOK'], + ['/dashboard/themes', 'VIEW_THEMES'], + ['/dashboard/extensions', 'VIEW_EXTENSIONS'], + ['/dashboard/extension-config', 'VIEW_EXTENSIONS'], + ['/dashboard/account', 'ACCESS_PANEL'], + ['/dashboard/world-backups', 'VIEW_BACKUPS'], + ['/dashboard/maintenance', 'VIEW_MAINTENANCE'], + ['/dashboard/whitelist', 'VIEW_WHITELIST'], + ['/dashboard/announcements', 'VIEW_ANNOUNCEMENTS'], + ['/dashboard/reports', 'VIEW_REPORTS'], + ['/dashboard/tickets', 'VIEW_TICKETS'] + ]); + + sideNav.querySelectorAll('a.side-link').forEach(link => { + const href = link.getAttribute('href') || ''; + const required = linkPermissions.get(href); + if (!required) { + return; + } + + const allowed = permissionSet.has(required); + link.style.display = allowed ? '' : 'none'; + }); + } + + async function fetchCurrentUser() { + const now = Date.now(); + try { + const cachedRaw = sessionStorage.getItem(CURRENT_USER_CACHE_KEY); + if (cachedRaw) { + const cached = JSON.parse(cachedRaw); + if (cached && typeof cached === 'object' && Number(cached.expiresAt || 0) > now) { + return cached.user || null; + } + } + } catch (ignored) { + // Ignore malformed cache state. + } + + try { + const response = await fetch('/api/me', { credentials: 'same-origin', cache: 'no-store' }); + if (!response.ok) { + return null; + } + + const payload = await response.json(); + const user = payload && payload.user ? payload.user : null; + + try { + sessionStorage.setItem(CURRENT_USER_CACHE_KEY, JSON.stringify({ + user, + expiresAt: now + CURRENT_USER_CACHE_TTL_MS + })); + } catch (ignored) { + // Ignore unavailable sessionStorage. + } + return user; + } catch (ignored) { + return null; + } + } + + function enforceServerCategoryOrder(sideNav) { + const serverContainer = sideNav.querySelector('.side-category-items[data-category-items="server"]'); + if (!serverContainer) { + return; + } + + const preferredOrder = [ + '/console', + '/dashboard/console', + '/dashboard/resources', + '/dashboard/world-backups', + '/dashboard/maintenance', + '/dashboard/whitelist', + '/dashboard/announcements', + '/dashboard/players', + '/dashboard/bans', + '/dashboard/plugins', + '/dashboard/reports', + '/dashboard/tickets' + ]; + + const links = Array.from(serverContainer.querySelectorAll('a.side-link')); + if (links.length <= 1) { + return; + } + + links.sort((left, right) => { + const leftHref = left.getAttribute('href') || ''; + const rightHref = right.getAttribute('href') || ''; + const leftIndex = preferredOrder.indexOf(leftHref); + const rightIndex = preferredOrder.indexOf(rightHref); + + if (leftIndex >= 0 && rightIndex >= 0) { + return leftIndex - rightIndex; + } + if (leftIndex >= 0) { + return -1; + } + if (rightIndex >= 0) { + return 1; + } + return leftHref.localeCompare(rightHref); + }); + + for (const link of links) { + serverContainer.appendChild(link); + } + } + + function ensureCategoryContainer(sideNav, category) { + let container = sideNav.querySelector(`.side-category-items[data-category-items="${category}"]`); + if (container) { + return container; + } + + // Allow extensions to define additional categories without touching every page template. + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'side-category-toggle'; + toggle.dataset.category = category; + toggle.textContent = category.charAt(0).toUpperCase() + category.slice(1); + + container = document.createElement('div'); + container.className = 'side-category-items'; + container.dataset.categoryItems = category; + + sideNav.appendChild(toggle); + sideNav.appendChild(container); + + // Keep behavior consistent with per-page sidebar category script. + toggle.addEventListener('click', () => { + const expanded = !container.classList.contains('expanded'); + container.classList.toggle('expanded', expanded); + toggle.classList.toggle('expanded', expanded); + }); + + return container; + } + + function ensurePanelExtensionsLink(sideNav) { + const panelContainer = ensureCategoryContainer(sideNav, 'panel'); + if (!panelContainer) { + return; + } + + const existing = panelContainer.querySelector('a.side-link[href="/dashboard/extensions"]'); + if (existing) { + if (window.location.pathname === '/dashboard/extensions') { + existing.classList.add('active'); + } + return; + } + + const link = document.createElement('a'); + link.className = 'side-link'; + if (window.location.pathname === '/dashboard/extensions') { + link.classList.add('active'); + } + link.href = '/dashboard/extensions'; + link.textContent = 'Extensions'; + panelContainer.appendChild(link); + } + + function ensurePanelAccountLink(sideNav) { + if (!sideNav) { + return; + } + + sideNav.querySelectorAll('a.side-link[href="/dashboard/account"]').forEach(link => { + if (link.dataset.accountBottomLink === 'true') { + return; + } + link.remove(); + }); + + let link = sideNav.querySelector('a.side-link[data-account-bottom-link="true"]'); + if (!link) { + link = document.createElement('a'); + link.className = 'side-link'; + link.dataset.accountBottomLink = 'true'; + link.href = '/dashboard/account'; + link.textContent = 'Account'; + } + + link.classList.remove('active'); + if (window.location.pathname === '/dashboard/account') { + link.classList.add('active'); + } + + // Keep Account as the bottom nav item below panel links/categories. + sideNav.appendChild(link); + } + + function ensurePanelExtensionConfigLink(sideNav) { + const panelContainer = ensureCategoryContainer(sideNav, 'panel'); + if (!panelContainer) { + return; + } + + const existing = panelContainer.querySelector('a.side-link[href="/dashboard/extension-config"]'); + if (existing) { + if (window.location.pathname === '/dashboard/extension-config') { + existing.classList.add('active'); + } + return; + } + + const link = document.createElement('a'); + link.className = 'side-link'; + if (window.location.pathname === '/dashboard/extension-config') { + link.classList.add('active'); + } + link.href = '/dashboard/extension-config'; + link.textContent = 'Extension Config'; + panelContainer.appendChild(link); + } + + function bootstrapExtensionNavigationTabs() { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + loadExtensionNavigationTabs(); + }); + return; + } + loadExtensionNavigationTabs(); + } + + function startLiveAssetRefresh() { + let lastVersion = null; + + async function checkVersion() { + try { + const response = await fetch('/api/web/live-version', { + credentials: 'same-origin', + cache: 'no-store' + }); + if (!response.ok) { + return; + } + + const payload = await response.json(); + const nextVersion = Number(payload.version || 0); + if (!Number.isFinite(nextVersion) || nextVersion <= 0) { + return; + } + + if (lastVersion === null) { + lastVersion = nextVersion; + return; + } + + if (nextVersion > lastVersion) { + window.location.reload(); + return; + } + + lastVersion = nextVersion; + } catch (ignored) { + // Ignore transient connectivity issues and try again on next interval. + } + } + + checkVersion(); + window.setInterval(() => { + if (document.hidden) { + return; + } + checkVersion(); + }, 5000); + } + + loadTheme(); + bootstrapExtensionNavigationTabs(); + startLiveAssetRefresh(); +})(); +