first commit

This commit is contained in:
Patrick
2026-05-01 18:46:17 +02:00
commit 61ae38701e
104 changed files with 20058 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.gradle
.idea
build
gradle
run
folia
+21
View File
@@ -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.
+149
View File
@@ -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-<version>.jar`
- Reports extension jar:
- `build/libs/extensions/MinePanel-Extension-Reports-<version>.jar`
- Player-management extension jar:
- `build/libs/extensions/MinePanel-Extension-PlayerManagement-<version>.jar`
- LuckPerms extension jar:
- `build/libs/extensions/MinePanel-Extension-LuckPerms-<version>.jar`
- Player Stats extension jar:
- `build/libs/extensions/MinePanel-Extension-PlayerStats-<version>.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.
+316
View File
@@ -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)
View File
Vendored
+249
View File
@@ -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" "$@"
Vendored
+92
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+1
View File
@@ -0,0 +1 @@
rootProject.name = 'MinePanel'
@@ -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("<aqua>MinePanel available at: </aqua>"), "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();
}
}
}
@@ -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);
}
}
@@ -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<Long> 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);
}
}
@@ -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();
}
}
}
@@ -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<String, List<RuntimeExtensionCommand>> 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<String> 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<RuntimeExtensionCommand> commands = commandsByExtensionId.remove(normalizedExtensionId);
if (commands == null || commands.isEmpty()) {
return;
}
CommandMap commandMap = getCommandMap();
if (commandMap == null) {
return;
}
Map<String, Command> knownCommands = getKnownCommands(commandMap);
for (RuntimeExtensionCommand command : commands) {
command.unregister(commandMap);
removeCommandEntries(knownCommands, command);
}
}
@Override
public synchronized void unregisterAll() {
List<String> extensionIds = new ArrayList<>(commandsByExtensionId.keySet());
for (String extensionId : extensionIds) {
unregisterForExtension(extensionId);
}
commandsByExtensionId.clear();
}
private void removeCommandEntries(Map<String, Command> knownCommands, RuntimeExtensionCommand command) {
if (knownCommands == null || knownCommands.isEmpty()) {
return;
}
Iterator<Map.Entry<String, Command>> iterator = knownCommands.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Command> entry = iterator.next();
if (entry.getValue() == command) {
iterator.remove();
}
}
}
@SuppressWarnings("unchecked")
private Map<String, Command> 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<String, Command>) 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<String> 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<String> tabComplete(CommandSender sender, String alias, String[] args) {
if (tabCompleter == null) {
return List.of();
}
List<String> completions = tabCompleter.onTabComplete(sender, this, alias, args);
return completions == null ? List.of() : completions;
}
@Override
public Plugin getPlugin() {
return plugin;
}
}
}
@@ -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<String> aliases,
CommandExecutor executor,
TabCompleter tabCompleter
);
default boolean register(
String extensionId,
String name,
String description,
String usage,
String permission,
List<String> aliases,
CommandExecutor executor
) {
return register(extensionId, name, description, usage, permission, aliases, executor, null);
}
void unregisterForExtension(String extensionId);
void unregisterAll();
}
@@ -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);
}
@@ -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
) {
}
@@ -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<MinePanelExtension> loadedExtensions = new ArrayList<>();
private final List<URLClassLoader> classLoaders = new ArrayList<>();
private final Set<String> loadedIds = new HashSet<>();
private final Set<String> loadedArtifacts = new HashSet<>();
private final Map<String, String> 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<Path> 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<MinePanelExtension> newlyLoaded = new ArrayList<>();
List<String> warnings = new ArrayList<>();
int scanned = 0;
try (Stream<Path> files = Files.list(extensionsDirectory)) {
List<Path> 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<String> loadedExtensionIds = newlyLoaded.stream().map(MinePanelExtension::id).toList();
return new ReloadResult(scanned, loadedExtensionIds, warnings);
}
public synchronized List<Map<String, Object>> installedExtensions() {
List<Map<String, Object>> 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<Map<String, Object>> availableArtifacts() {
List<String> artifacts = scanArtifactFileNames();
Set<String> loadedArtifacts = new HashSet<>();
for (String source : extensionSourceById.values()) {
if (source != null && source.toLowerCase(Locale.ROOT).endsWith(".jar")) {
loadedArtifacts.add(source);
}
}
List<Map<String, Object>> 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<ExtensionNavigationTab> navigationTabs() {
List<ExtensionNavigationTab> 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<MinePanelExtension> 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<MinePanelExtension> newlyLoaded) {
URLClassLoader classLoader = null;
try {
URL url = jarFile.toUri().toURL();
classLoader = new URLClassLoader(new URL[]{url}, plugin.getClass().getClassLoader());
ServiceLoader<MinePanelExtension> 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<String> loadedExtensionIds, List<String> warnings) {
}
private List<String> scanArtifactFileNames() {
if (extensionsDirectory == null) {
return List.of();
}
try (Stream<Path> 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<Path> jars;
try (Stream<Path> 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<String, Path> keepByKey = new HashMap<>();
Map<String, ArtifactVersionToken> 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<Integer> 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<Integer> 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<Integer> parts) {
return new ArtifactVersionToken(ArtifactVersionKind.SEMVER, -1, parts == null ? List.of() : List.copyOf(parts));
}
}
}
@@ -0,0 +1,9 @@
package de.winniepat.minePanel.extensions;
public record ExtensionNavigationTab(
String category,
String label,
String path
) {
}
@@ -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;
}
@@ -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<String, Object> payload);
}
@@ -0,0 +1,88 @@
package de.winniepat.minePanel.extensions;
import java.util.List;
/**
* Service Provider Interface (SPI) for MinePanel extensions.
* <p>
* Implementations are discovered through Java {@code ServiceLoader} and loaded by the
* extension manager during MinePanel startup.
* </p>
* <p>
* Typical lifecycle:
* </p>
* <ol>
* <li>{@link #onLoad(ExtensionContext)}</li>
* <li>{@link #onEnable()}</li>
* <li>{@link #registerWebRoutes(ExtensionWebRegistry)}</li>
* <li>{@link #navigationTabs()}</li>
* <li>{@link #onDisable()} on shutdown</li>
* </ol>
*/
public interface MinePanelExtension {
/**
* Returns the stable, unique id of this extension.
* <p>
* The id is used for runtime bookkeeping and should remain unchanged across versions.
* </p>
*
* @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.
* <p>
* Use this for initialization that requires MinePanel services, such as setting up
* database schema or caching dependencies from the provided context.
* </p>
*
* @param context runtime context provided by MinePanel
*/
default void onLoad(ExtensionContext context) {
}
/**
* Called after {@link #onLoad(ExtensionContext)} when the extension should become active.
* <p>
* Use this for registering listeners, commands, or background tasks.
* </p>
*/
default void onEnable() {
}
/**
* Called when MinePanel is disabling the extension.
* <p>
* Use this hook to release resources and stop tasks started in {@link #onEnable()}.
* </p>
*/
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<ExtensionNavigationTab> navigationTabs() {
return List.of();
}
}
@@ -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<Object>) 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<KnownPlayer> 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) {
}
}
@@ -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
) {
}
@@ -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<Announcement> listMessages() {
List<Announcement> 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) {
}
}
@@ -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<Announcement> 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<Player> 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<Announcement> messages
) {
}
}
@@ -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<Map<String, Object>> messages = state.messages().stream()
.map(message -> Map.<String, Object>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<ExtensionNavigationTab> 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) {
}
}
@@ -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<String> groups = lpUser.getInheritedGroups(queryOptions).stream()
.map(Group::getName)
.distinct()
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
List<String> 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<String> limitedPermissions = permissions.stream().limit(200).toList();
boolean permissionsTruncated = permissions.size() > limitedPermissions.size();
Map<String, Object> 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;
}
}
@@ -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<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Maintenance", "/dashboard/maintenance"));
}
private Map<String, Object> toStatusPayload(boolean ok, int kicked) {
MaintenanceService.MaintenanceSnapshot snapshot = maintenanceService.snapshot();
Map<String, Object> 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) {
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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
) {
}
}
@@ -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<KnownPlayer> 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<String, Object> 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<KnownPlayer> 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<PlayerMute> 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<KnownPlayer> resolveTarget(String rawUuid, String rawUsername) {
if (!isBlank(rawUuid)) {
try {
UUID uuid = UUID.fromString(rawUuid.trim());
Optional<KnownPlayer> known = context.knownPlayerRepository().findByUuid(uuid);
if (known.isPresent()) {
return known;
}
} catch (IllegalArgumentException ignored) {
// Fallback to username lookup.
}
}
if (!isBlank(rawUsername)) {
Optional<KnownPlayer> 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<String> parseBadWords(List<String> configuredWords) {
Set<String> 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<String> 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<String> readWords(JsonObject filter) {
if (filter == null || !filter.has("words") || !filter.get("words").isJsonArray()) {
return List.of();
}
JsonArray array = filter.getAsJsonArray("words");
java.util.ArrayList<String> 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<String> 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) {
}
}
@@ -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);
}
}
@@ -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<String> badWords;
private volatile int autoMuteMinutes;
private volatile String autoMuteReason;
private volatile boolean cancelBlockedMessage;
public PlayerMuteListener(
PlayerMuteRepository muteRepository,
PanelLogger panelLogger,
boolean badWordFilterEnabled,
Set<String> 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<String> 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<PlayerMute> 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);
}
}
@@ -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<PlayerMute> 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
);
}
}
@@ -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<String, Object> 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, "-", "");
}
}
}
@@ -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
) {
}
@@ -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 <player> <reason>");
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;
}
}
@@ -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<PlayerReport> 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<PlayerReport> 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<PlayerReport> 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")
);
}
}
@@ -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 <player> <reason>",
"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<Map<String, Object>> 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<PlayerReport> 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<String, Object> 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<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Reports", "/dashboard/reports"));
}
private Map<String, Object> 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) {
}
}
@@ -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
) {
}
@@ -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;
}
}
@@ -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<Integer, TicketTemplate> 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<String> 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) {
}
}
@@ -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<PlayerTicket> 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<PlayerTicket> 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")
);
}
}
@@ -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<Map<String, Object>> 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<ExtensionNavigationTab> 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<String, Object> 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()
);
}
}
@@ -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<Map<String, Object>> 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<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Whitelist", "/dashboard/whitelist"));
}
private record UsernamePayload(String username) {
}
private record TogglePayload(Boolean enabled) {
}
}
@@ -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<Map<String, Object>> listEntries() {
return runSync(() -> {
List<Map<String, Object>> 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> T runSync(Callable<T> 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, "");
}
}
}
@@ -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<String> 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<String> 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<WorldBackupEntry> 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<String> 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) {
}
}
@@ -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<Map<String, Object>> backups = backupService.listBackups(world).stream().map(this::toPayload).toList();
return webRegistry.json(response, 200, Map.of("world", world, "backups", backups));
}
Map<String, Object> payload = new HashMap<>();
for (String worldKey : backupService.supportedWorldKeys()) {
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Backups", "/dashboard/world-backups"));
}
private Map<String, Object> 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) {
}
}
@@ -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
);
}
}
@@ -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;
}
}
@@ -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<DiscordWebhookConfig> 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<String, Object> 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<String> 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) + "...";
}
}
@@ -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("<gold> __ __ ____ </gold>"));
componentLogger.info(miniMessage.deserialize("<gold>| \\/ | | _ \\ </gold>"));
componentLogger.info("{}{}", miniMessage.deserialize("<gold>| |\\/| | | |_) | MinePanel: </gold>"), miniMessage.deserialize("<green>" + plugin.getDescription().getVersion() + "</green>"));
componentLogger.info("{}{}", miniMessage.deserialize("<gold>| | | | | __/ Running on: </gold>"), miniMessage.deserialize("<aqua>" + plugin.getServer().getName() + "</aqua>" + "(" + plugin.getServer().getMinecraftVersion() + ")"));
componentLogger.info(miniMessage.deserialize("<gold>|_| |_| |_| </gold>"));
plugin.getLogger().info("");
}
public static void announceBootstrapToken(BootstrapService bootstrapService, ComponentLogger componentLogger, MiniMessage miniMessage) {
bootstrapService.getBootstrapToken().ifPresent(token -> {
componentLogger.info("{}{}", miniMessage.deserialize("<dark_green>First launch setup token: </dark_green>"), 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());
}
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -0,0 +1,11 @@
package de.winniepat.minePanel.logs;
public record PanelLogEntry(
long id,
String kind,
String source,
String message,
long createdAt
) {
}
@@ -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);
}
}
@@ -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<String> 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;
}
}
@@ -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<String> readLatestLines(int lines) {
return readLogLines("latest.log", lines);
}
public List<String> 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<String> 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<String> listLogFiles() {
if (!Files.exists(logDirectory) || !Files.isDirectory(logDirectory)) {
return Collections.emptyList();
}
try (Stream<Path> 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;
}
}
}
@@ -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() {
}
}
@@ -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<String> 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();
}
}
@@ -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<JoinLeaveEvent> 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<JoinLeaveEvent> 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) {
}
}
@@ -0,0 +1,11 @@
package de.winniepat.minePanel.persistence;
import java.util.UUID;
public record KnownPlayer(
UUID uuid,
String username,
long lastSeenAt
) {
}
@@ -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<KnownPlayer> findAll() {
String sql = "SELECT uuid, username, last_seen_at FROM known_players";
List<KnownPlayer> 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<KnownPlayer> 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<KnownPlayer> 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);
}
}
}
@@ -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<PanelLogEntry> 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<PanelLogEntry> 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<PanelLogEntry> allLogsAscending() {
String sql = "SELECT id, kind, source, message, created_at FROM panel_logs ORDER BY id ASC";
List<PanelLogEntry> 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);
}
}
}
@@ -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<Long> 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<OAuthAccountLink> 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<OAuthAccountLink> 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<OAuthAccountLink> 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
) {
}
}
@@ -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<OAuthState> 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
) {
}
}
@@ -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
) {
}
@@ -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<PlayerActivity> 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<UUID, PlayerActivity> 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<UUID, PlayerActivity> 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")
);
}
}
@@ -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<Long> 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<PanelPermission> 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<PanelPermission> 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<PanelUserAuth> 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<PanelUser> 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<PanelUser> 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<PanelUser> 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<Long, Set<PanelPermission>> 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<PanelPermission> 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<PanelPermission> permissions) {
Optional<PanelUser> target = findById(userId);
if (target.isEmpty()) {
return false;
}
Set<PanelPermission> 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<PanelPermission> loadPermissions(Connection connection, long userId, UserRole role) throws SQLException {
String sql = "SELECT permission FROM user_permissions WHERE user_id = ?";
Set<PanelPermission> 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<PanelPermission> 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<PanelPermission> sanitizePermissions(Set<PanelPermission> permissions, UserRole role) {
if (role == UserRole.OWNER) {
return EnumSet.allOf(PanelPermission.class);
}
Set<PanelPermission> source = permissions == null || permissions.isEmpty() ? role.defaultPermissions() : permissions;
Set<PanelPermission> 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;
}
}
@@ -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;
}
}
@@ -0,0 +1,41 @@
package de.winniepat.minePanel.users;
import java.util.Set;
public record PanelUser(
long id,
String username,
UserRole role,
Set<PanelPermission> 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;
}
}
@@ -0,0 +1,8 @@
package de.winniepat.minePanel.users;
public record PanelUserAuth(
PanelUser user,
String passwordHash
) {
}
@@ -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<PanelPermission> permissions;
UserRole(Set<PanelPermission> permissions) {
this.permissions = permissions;
}
public boolean hasPermission(PanelPermission permission) {
return permissions.contains(permission);
}
public Set<PanelPermission> defaultPermissions() {
return EnumSet.copyOf(permissions);
}
public static UserRole fromString(String raw) {
return UserRole.valueOf(raw.toUpperCase(Locale.ROOT));
}
}
@@ -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<Object>) 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<Object> scheduledTaskRef = new AtomicReference<>();
class FoliaRepeatingState {
void scheduleNext(long delayTicks) {
if (cancelled.get()) {
return;
}
try {
Object scheduledTask = globalRunDelayedMethod.invoke(
globalRegionScheduler,
plugin,
(Consumer<Object>) 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<Object>) 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<Object>) ignored -> task.run());
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("could_not_schedule_async_task", exception);
}
}
public <T> T callGlobal(Callable<T> task, long timeout, TimeUnit timeUnit)
throws InterruptedException, ExecutionException, TimeoutException {
CompletableFuture<T> 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();
}
}
@@ -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<String> 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);
}
}
@@ -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);
}
}
}
@@ -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<String> 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<String, CachedTextAsset> 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) {
}
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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
+20
View File
@@ -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
@@ -0,0 +1,240 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Account</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
<style>
.oauth-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 12px;
}
.oauth-provider {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
padding: 14px;
}
.oauth-provider h3 {
margin: 0 0 6px;
}
.oauth-meta {
color: var(--text-muted);
font-size: 13px;
margin-bottom: 10px;
}
.oauth-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.oauth-status {
margin-top: 8px;
font-size: 13px;
color: var(--text-muted);
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
<a class="side-link active" href="/dashboard/account">Account</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Account Settings</h1>
<section class="card spaced-top">
<h2>Linked OAuth Accounts</h2>
<p>Link Google or Discord for one-click login on the login page.</p>
<p id="status" class="action-status"></p>
<div id="providers" class="oauth-grid"></div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
function setStatus(message, isError) {
const status = document.getElementById('status');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function providerCard(provider) {
const linked = !!provider.linked;
const enabled = !!provider.enabled;
const configured = !!provider.configured;
const displayName = (provider.displayName || '').trim();
const email = (provider.email || '').trim();
const subtitle = linked
? [displayName || 'Linked account', email].filter(Boolean).join(' - ')
: 'Not linked';
return `
<article class="oauth-provider">
<h3>${provider.label}</h3>
<div class="oauth-meta">${subtitle}</div>
<div class="oauth-actions">
<button data-link="${provider.provider}" ${!enabled || !configured ? 'disabled' : ''}>Link</button>
<button class="secondary" data-unlink="${provider.provider}" ${!linked ? 'disabled' : ''}>Unlink</button>
</div>
<div class="oauth-status">${!enabled ? 'Disabled in config' : (!configured ? 'Missing OAuth credentials' : (linked ? 'Linked' : 'Ready to link'))}</div>
</article>
`;
}
async function loadProviders() {
const data = await api('/api/account/links');
const container = document.getElementById('providers');
const providers = Array.isArray(data.providers) ? data.providers : [];
container.innerHTML = providers.map(providerCard).join('');
container.querySelectorAll('button[data-link]').forEach(button => {
button.addEventListener('click', async () => {
const provider = button.getAttribute('data-link');
try {
const start = await api(`/api/oauth/${provider}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'link' })
});
window.location.href = start.authUrl;
} catch (error) {
setStatus(`Could not start ${provider} linking: ${error.message}`, true);
}
});
});
container.querySelectorAll('button[data-unlink]').forEach(button => {
button.addEventListener('click', async () => {
const provider = button.getAttribute('data-unlink');
if (!confirm(`Unlink ${provider} from your panel account?`)) {
return;
}
try {
await api(`/api/oauth/${provider}/unlink`, { method: 'POST' });
setStatus(`${provider} unlinked.`, false);
await loadProviders();
} catch (error) {
setStatus(`Could not unlink ${provider}: ${error.message}`, true);
}
});
});
}
function showOAuthQueryState() {
const url = new URL(window.location.href);
const oauth = (url.searchParams.get('oauth') || '').trim();
if (!oauth) {
return;
}
if (oauth === 'linked') {
setStatus('OAuth account linked successfully.', false);
} else {
setStatus(`OAuth status: ${oauth}`, true);
}
url.searchParams.delete('oauth');
window.history.replaceState({}, document.title, url.pathname + (url.search ? `?${url.searchParams.toString()}` : ''));
}
document.getElementById('logout').addEventListener('click', async () => {
try {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
} catch (error) {
setStatus(`Logout failed: ${error.message}`, true);
}
});
(async () => {
try {
initSidebarCategories();
await loadMe();
showOAuthQueryState();
await loadProviders();
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,444 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Announcements</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
<style>
.announce-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.announce-stat {
border: 1px solid var(--card-border);
border-radius: 10px;
padding: 12px;
background: var(--panel-bg);
}
.announce-stat-title {
font-size: 12px;
color: var(--muted-text);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 5px;
}
.announce-stat-value {
font-size: 16px;
font-weight: 700;
}
.announce-list {
display: grid;
gap: 8px;
margin-top: 12px;
}
.announce-item {
border: 1px solid var(--table-row-border);
border-radius: 8px;
padding: 10px;
background: var(--panel-bg);
display: grid;
gap: 8px;
}
.announce-item-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.announce-meta {
color: var(--muted-text);
font-size: 12px;
}
.announce-message {
white-space: pre-wrap;
word-break: break-word;
}
.announce-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted-text);
}
.announce-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.announce-message-input {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text);
padding: 10px 11px;
font-size: 14px;
line-height: 1.4;
min-height: 90px;
resize: vertical;
}
.announce-message-input:focus {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
.announce-empty {
color: var(--muted-text);
font-size: 14px;
margin-top: 8px;
}
.announce-placeholders {
margin-top: 8px;
color: var(--muted-text);
font-size: 13px;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
<a class="side-link" href="/dashboard/extensions">Extensions</a>
<a class="side-link" href="/dashboard/extension-config">Extension Config</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Announcements</h1>
<p>Broadcast rotating server announcements to all online players.</p>
<section class="card">
<h2>Status</h2>
<div class="announce-grid">
<div class="announce-stat">
<div class="announce-stat-title">State</div>
<div id="stateValue" class="announce-stat-value">Disabled</div>
</div>
<div class="announce-stat">
<div class="announce-stat-title">Interval</div>
<div id="intervalValue" class="announce-stat-value">300s</div>
</div>
<div class="announce-stat">
<div class="announce-stat-title">Next Broadcast</div>
<div id="nextRunValue" class="announce-stat-value">-</div>
</div>
<div class="announce-stat">
<div class="announce-stat-title">Messages</div>
<div id="countValue" class="announce-stat-value">0</div>
</div>
</div>
</section>
<section class="card spaced-top">
<h2>Configuration</h2>
<label class="checkbox-line"><input id="enabledInput" type="checkbox"> Enable automatic announcements</label>
<label for="intervalInput">Broadcast interval (seconds)</label>
<input id="intervalInput" type="number" min="10" max="86400" value="300">
<div class="announce-toolbar">
<button id="saveConfigBtn">Save Configuration</button>
<button id="sendNowBtn" class="secondary">Send Next Message Now</button>
<button id="refreshBtn" class="secondary">Refresh</button>
</div>
<p id="status" class="action-status"></p>
</section>
<section class="card spaced-top">
<h2>Create Message</h2>
<textarea id="messageInput" class="announce-message-input" maxlength="220" placeholder="Welcome to the server! Use /ticket if you need support."></textarea>
<p class="announce-placeholders">
Add your own prefix directly in the message text (for example <code>[Network]</code> or <code>&lt;gold&gt;[MinePanel]&lt;/gold&gt;</code>).<br>
Placeholders: <code>%player%</code>, <code>%time%</code>, <code>%date%</code>, <code>%datetime%</code>, <code>%online%</code>, <code>%max_players%</code>, <code>%world%</code>, <code>%server%</code>, <code>%tps%</code>.
</p>
<div class="announce-toolbar">
<button id="createBtn">Add Message</button>
</div>
</section>
<section class="card spaced-top">
<h2>Messages</h2>
<div id="messageList" class="announce-list"></div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const nextExpanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', nextExpanded);
toggle.classList.toggle('expanded', nextExpanded);
savedState[category] = nextExpanded;
persistState();
});
});
}
async function api(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
const error = new Error(payload.error || 'Request failed');
error.status = response.status;
throw error;
}
return payload;
}
function setStatus(message, isError) {
const status = document.getElementById('status');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function formatTimestamp(timestamp) {
const millis = Number(timestamp || 0);
if (!Number.isFinite(millis) || millis <= 0) {
return '-';
}
return new Date(millis).toLocaleString();
}
function applyState(state) {
const enabled = state && state.enabled === true;
const interval = Number(state && state.intervalSeconds ? state.intervalSeconds : 300);
const messages = Array.isArray(state && state.messages) ? state.messages : [];
document.getElementById('stateValue').textContent = enabled ? 'Enabled' : 'Disabled';
document.getElementById('intervalValue').textContent = `${interval}s`;
document.getElementById('nextRunValue').textContent = formatTimestamp(state ? state.nextRunAt : 0);
document.getElementById('countValue').textContent = String(messages.length);
document.getElementById('enabledInput').checked = enabled;
document.getElementById('intervalInput').value = String(interval);
renderMessages(messages);
}
function renderMessages(messages) {
const list = document.getElementById('messageList');
list.innerHTML = '';
if (!Array.isArray(messages) || messages.length === 0) {
const empty = document.createElement('p');
empty.className = 'announce-empty';
empty.textContent = 'No announcements configured yet.';
list.appendChild(empty);
return;
}
for (const entry of messages) {
const item = document.createElement('div');
item.className = 'announce-item';
const header = document.createElement('div');
header.className = 'announce-item-header';
const meta = document.createElement('div');
meta.className = 'announce-meta';
meta.textContent = `#${entry.id} · Updated ${formatTimestamp(entry.updatedAt)}`;
const controls = document.createElement('div');
controls.className = 'announce-toolbar';
const toggleLabel = document.createElement('label');
toggleLabel.className = 'announce-toggle';
toggleLabel.innerHTML = `<input type="checkbox" ${entry.enabled ? 'checked' : ''}> Enabled`;
toggleLabel.querySelector('input').addEventListener('change', async (event) => {
try {
await api(`/api/extensions/announcements/messages/${entry.id}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ enabled: !!event.target.checked })
});
await refresh();
} catch (error) {
setStatus(`Could not update message #${entry.id}: ${error.message}`, true);
}
});
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'danger';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', async () => {
try {
await api(`/api/extensions/announcements/messages/${entry.id}/delete`, {
method: 'POST',
credentials: 'same-origin'
});
setStatus(`Deleted announcement #${entry.id}`, false);
await refresh();
} catch (error) {
setStatus(`Could not delete message #${entry.id}: ${error.message}`, true);
}
});
controls.appendChild(toggleLabel);
controls.appendChild(deleteButton);
header.appendChild(meta);
header.appendChild(controls);
const message = document.createElement('div');
message.className = 'announce-message';
message.textContent = entry.message || '';
item.appendChild(header);
item.appendChild(message);
list.appendChild(item);
}
}
async function refresh() {
const state = await api('/api/extensions/announcements', { credentials: 'same-origin', cache: 'no-store' });
applyState(state);
}
async function saveConfig() {
const enabled = document.getElementById('enabledInput').checked;
const interval = Number(document.getElementById('intervalInput').value || 300);
try {
await api('/api/extensions/announcements/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ enabled, intervalSeconds: interval })
});
setStatus('Configuration saved', false);
await refresh();
} catch (error) {
setStatus(`Could not save configuration: ${error.message}`, true);
}
}
async function createMessage() {
const input = document.getElementById('messageInput');
const message = (input.value || '').trim();
if (!message) {
setStatus('Enter a message first.', true);
return;
}
try {
await api('/api/extensions/announcements/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ message })
});
input.value = '';
setStatus('Announcement added', false);
await refresh();
} catch (error) {
setStatus(`Could not create announcement: ${error.message}`, true);
}
}
async function sendNow() {
try {
await api('/api/extensions/announcements/send-now', {
method: 'POST',
credentials: 'same-origin'
});
setStatus('Sent next announcement', false);
await refresh();
} catch (error) {
setStatus(`Could not send announcement: ${error.message}`, true);
}
}
async function logout() {
await api('/api/logout', { method: 'POST', credentials: 'same-origin' });
window.location.href = '/';
}
document.getElementById('saveConfigBtn').addEventListener('click', saveConfig);
document.getElementById('createBtn').addEventListener('click', createMessage);
document.getElementById('sendNowBtn').addEventListener('click', sendNow);
document.getElementById('refreshBtn').addEventListener('click', refresh);
document.getElementById('logout').addEventListener('click', logout);
(async function boot() {
try {
initSidebarCategories();
await loadMe();
await refresh();
} catch (error) {
if (error && error.status === 401) {
window.location.href = '/';
return;
}
setStatus(`Could not load announcements page: ${error.message}`, true);
}
})();
</script>
</body>
</html>
+191
View File
@@ -0,0 +1,191 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Bans</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link active" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Bans</h1>
<p>Current bans: <span id="banCount">0</span></p>
<section class="card spaced-top">
<h2>Ban List</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Player</th>
<th>Status</th>
<th>Expires</th>
<th>Action</th>
</tr>
</thead>
<tbody id="bans"></tbody>
</table>
</div>
<p id="banStatus" class="action-status"></p>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
function setStatus(message, isError) {
const status = document.getElementById('banStatus');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
async function loadBans() {
const players = await api('/api/players');
const allPlayers = Array.isArray(players.allPlayers) ? players.allPlayers : [];
const bans = allPlayers.filter(player => player.banned);
document.getElementById('banCount').textContent = String(bans.length);
const tbody = document.getElementById('bans');
tbody.innerHTML = '';
if (bans.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 4;
cell.textContent = 'No active bans';
row.appendChild(cell);
tbody.appendChild(row);
return;
}
bans.forEach(player => {
const row = document.createElement('tr');
const playerCell = document.createElement('td');
playerCell.textContent = player.username;
row.appendChild(playerCell);
const statusCell = document.createElement('td');
statusCell.textContent = player.online ? 'Online' : 'Offline';
row.appendChild(statusCell);
const expiresCell = document.createElement('td');
expiresCell.textContent = player.banExpiresAt ? new Date(player.banExpiresAt).toLocaleString() : 'Permanent';
row.appendChild(expiresCell);
const actionCell = document.createElement('td');
const button = document.createElement('button');
button.className = 'secondary';
button.textContent = 'Unban';
button.addEventListener('click', async () => {
try {
await api('/api/players/unban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: player.username, uuid: player.uuid })
});
setStatus(`Unbanned ${player.username}`, false);
await loadBans();
} catch (error) {
setStatus(`Failed to unban ${player.username}: ${error.message}`, true);
}
});
actionCell.appendChild(button);
row.appendChild(actionCell);
tbody.appendChild(row);
});
}
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await Promise.all([loadMe(), loadBans()]);
setInterval(() => {
loadBans().catch(() => {});
}, 5000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,181 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Console</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link active" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Console</h1>
<div class="toolbar">
<button id="refresh" class="secondary">Refresh Logs</button>
</div>
<section class="card">
<h2>Panel Logs + Chat + Commands</h2>
<pre id="panelLogs" class="log-rectangle"></pre>
<div class="console-send-row">
<input id="consoleInput" placeholder="Type a console command (example: say hello world)">
<button id="sendConsole">Send</button>
</div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
let latestPanelLogId = 0;
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
async function loadLogs() {
const data = await api('/api/logs?limit=250');
const panelLogsElement = document.getElementById('panelLogs');
panelLogsElement.textContent = [...data.panelLogs]
.reverse()
.map(x => `[${new Date(x.createdAt).toISOString()}] [${x.kind}] [${x.source}] ${x.message}`)
.join('\n');
if (data.panelLogs.length > 0) {
latestPanelLogId = Math.max(latestPanelLogId, ...data.panelLogs.map(x => x.id));
}
panelLogsElement.scrollTop = panelLogsElement.scrollHeight;
}
async function watchForNewLogs() {
const data = await api('/api/logs/latest');
if (data.latestId > latestPanelLogId) {
await loadLogs();
}
}
async function sendConsoleMessage() {
const input = document.getElementById('consoleInput');
const message = input.value.trim();
if (!message) {
return;
}
await api('/api/console/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
input.value = '';
await loadLogs();
}
document.getElementById('refresh').addEventListener('click', async () => {
await loadLogs();
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
document.getElementById('sendConsole').addEventListener('click', async () => {
await sendConsoleMessage();
});
document.getElementById('consoleInput').addEventListener('keydown', async event => {
if (event.key === 'Enter') {
event.preventDefault();
await sendConsoleMessage();
}
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadLogs();
setInterval(() => {
watchForNewLogs().catch(() => {});
}, 1000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,216 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Discord Webhook</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link active" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Discord Webhook</h1>
<p>Configure what MinePanel sends to your Discord channel.</p>
<section class="card spaced-top">
<h2>Webhook Settings</h2>
<div class="integration-form">
<label class="label checkbox-line">
<input id="enabled" type="checkbox">
Enable Discord webhook logging
</label>
<div class="label">Webhook URL</div>
<input id="webhookUrl" placeholder="https://discord.com/api/webhooks/...">
<div class="label">Bot Name</div>
<input id="botName" placeholder="MinePanel">
<label class="label checkbox-line">
<input id="useEmbed" type="checkbox">
Send as embed
</label>
<div class="label">Message Template</div>
<input id="messageTemplate" placeholder="[{timestamp}] [{kind}] [{source}] {message}">
<div class="label">Embed Title Template</div>
<input id="embedTitleTemplate" placeholder="MinePanel {kind}">
<div class="label">Event Filters</div>
<div class="toggle-grid">
<label class="checkbox-line"><input id="logChat" type="checkbox">Chat</label>
<label class="checkbox-line"><input id="logCommands" type="checkbox">Commands</label>
<label class="checkbox-line"><input id="logAuth" type="checkbox">Auth</label>
<label class="checkbox-line"><input id="logAudit" type="checkbox">Audit</label>
<label class="checkbox-line"><input id="logSecurity" type="checkbox">Security (Alt Alerts)</label>
<label class="checkbox-line"><input id="logConsoleResponse" type="checkbox">Console Response</label>
<label class="checkbox-line"><input id="logSystem" type="checkbox">System</label>
</div>
<button id="saveWebhook">Save Webhook Settings</button>
<p id="webhookStatus" class="action-status"></p>
</div>
</section>
<section class="card spaced-top">
<h2>Template Variables</h2>
<p class="template-help">Use <code>{timestamp}</code>, <code>{kind}</code>, <code>{source}</code>, and <code>{message}</code> in your templates.</p>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
function setStatus(message, isError) {
const status = document.getElementById('webhookStatus');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function applyConfig(config) {
document.getElementById('enabled').checked = !!config.enabled;
document.getElementById('webhookUrl').value = config.webhookUrl || '';
document.getElementById('useEmbed').checked = !!config.useEmbed;
document.getElementById('botName').value = config.botName || '';
document.getElementById('messageTemplate').value = config.messageTemplate || '';
document.getElementById('embedTitleTemplate').value = config.embedTitleTemplate || '';
document.getElementById('logChat').checked = !!config.logChat;
document.getElementById('logCommands').checked = !!config.logCommands;
document.getElementById('logAuth').checked = !!config.logAuth;
document.getElementById('logAudit').checked = !!config.logAudit;
document.getElementById('logSecurity').checked = !!config.logSecurity;
document.getElementById('logConsoleResponse').checked = !!config.logConsoleResponse;
document.getElementById('logSystem').checked = !!config.logSystem;
}
function collectConfig() {
return {
enabled: document.getElementById('enabled').checked,
webhookUrl: document.getElementById('webhookUrl').value,
useEmbed: document.getElementById('useEmbed').checked,
botName: document.getElementById('botName').value,
messageTemplate: document.getElementById('messageTemplate').value,
embedTitleTemplate: document.getElementById('embedTitleTemplate').value,
logChat: document.getElementById('logChat').checked,
logCommands: document.getElementById('logCommands').checked,
logAuth: document.getElementById('logAuth').checked,
logAudit: document.getElementById('logAudit').checked,
logSecurity: document.getElementById('logSecurity').checked,
logConsoleResponse: document.getElementById('logConsoleResponse').checked,
logSystem: document.getElementById('logSystem').checked
};
}
async function loadWebhookConfig() {
const config = await api('/api/integrations/discord-webhook');
applyConfig(config);
}
async function saveWebhookConfig() {
const payload = collectConfig();
const response = await api('/api/integrations/discord-webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
applyConfig(response.config);
}
document.getElementById('saveWebhook').addEventListener('click', async () => {
try {
await saveWebhookConfig();
setStatus('Webhook settings saved', false);
} catch (error) {
setStatus(`Save failed: ${error.message}`, true);
}
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await Promise.all([loadMe(), loadWebhookConfig()]);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,543 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Extension Config</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
<style>
.config-grid {
display: grid;
gap: 12px;
grid-template-columns: minmax(220px, 280px) 1fr;
}
.config-list {
display: grid;
gap: 8px;
}
.config-item {
border: 1px solid var(--table-row-border);
background: var(--panel-bg);
border-radius: 8px;
padding: 10px;
cursor: pointer;
color: var(--text);
text-align: left;
width: 100%;
}
.config-item.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.config-item small {
display: block;
color: var(--text-muted);
margin-top: 3px;
}
.config-form {
display: grid;
gap: 10px;
}
.field-label {
margin-bottom: 4px;
color: var(--text-muted);
font-size: 13px;
display: block;
}
.word-list {
min-height: 130px;
width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text);
padding: 10px 11px;
font-size: 14px;
line-height: 1.35;
resize: vertical;
font-family: inherit;
}
.word-list:focus {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
.kv-list {
display: grid;
gap: 8px;
}
.kv-row {
display: grid;
gap: 8px;
grid-template-columns: 1.1fr 1.3fr 130px auto;
align-items: center;
}
.kv-row button {
width: auto;
min-width: 84px;
}
.hint {
margin-top: 10px;
color: var(--text-muted);
font-size: 13px;
}
@media (max-width: 980px) {
.config-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
<a class="side-link" href="/dashboard/extensions">Extensions</a>
<a class="side-link active" href="/dashboard/extension-config">Extension Config</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Extension Config</h1>
<p>Configure extension-specific settings from the panel using form fields.</p>
<section class="card">
<div class="config-grid">
<div>
<h2>Installed Extensions</h2>
<div id="extensionList" class="config-list"></div>
</div>
<div>
<h2 id="editorTitle">Settings</h2>
<div id="configForm" class="config-form"></div>
<div class="toolbar">
<button id="addFieldBtn" class="secondary" style="display:none;">Add Field</button>
<button id="saveBtn">Save Settings</button>
<button id="reloadBtn" class="secondary">Reload</button>
</div>
<p id="status" class="action-status"></p>
</div>
</div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expandedNow = !items.classList.contains('expanded');
items.classList.toggle('expanded', expandedNow);
toggle.classList.toggle('expanded', expandedNow);
savedState[category] = expandedNow;
persistState();
});
});
}
async function api(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
const error = new Error(payload.error || 'Request failed');
error.status = response.status;
throw error;
}
return payload;
}
function setStatus(message, isError) {
const status = document.getElementById('status');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
let extensions = [];
let selectedExtensionId = '';
let selectedSettings = {};
function defaultPlayerManagementSettings() {
return {
badWordFilter: {
enabled: false,
words: [],
autoMuteMinutes: 15,
autoMuteReason: 'Inappropriate language',
cancelMessage: true
}
};
}
function renderExtensionList() {
const list = document.getElementById('extensionList');
list.innerHTML = '';
if (!Array.isArray(extensions) || extensions.length === 0) {
const noData = document.createElement('div');
noData.textContent = 'No installed extensions found.';
list.appendChild(noData);
return;
}
for (const extension of extensions) {
const id = extension.id || '';
const displayName = extension.displayName || id;
const source = extension.source || 'unknown';
const button = document.createElement('button');
button.type = 'button';
button.className = 'config-item' + (id === selectedExtensionId ? ' active' : '');
button.innerHTML = `<strong>${displayName}</strong><small>${id} - ${source}</small>`;
button.addEventListener('click', () => selectExtension(id));
list.appendChild(button);
}
}
function findExtension(extensionId) {
return extensions.find(x => (x.id || '') === extensionId) || null;
}
function clearConfigForm(message) {
const form = document.getElementById('configForm');
form.innerHTML = '';
const placeholder = document.createElement('p');
placeholder.className = 'hint';
placeholder.textContent = message || 'Select an extension to configure.';
form.appendChild(placeholder);
}
function renderPlayerManagementForm(settings) {
const merged = {
...defaultPlayerManagementSettings(),
...(settings && typeof settings === 'object' ? settings : {})
};
const filter = {
...defaultPlayerManagementSettings().badWordFilter,
...(merged.badWordFilter && typeof merged.badWordFilter === 'object' ? merged.badWordFilter : {})
};
const words = Array.isArray(filter.words) ? filter.words.join('\n') : '';
const form = document.getElementById('configForm');
form.innerHTML = `
<label class="checkbox-line"><input id="pmEnabled" type="checkbox"> Enable bad-word filter</label>
<div>
<label class="field-label" for="pmWords">Blocked words (one per line)</label>
<textarea id="pmWords" class="word-list" placeholder="word1\nword2"></textarea>
</div>
<div>
<label class="field-label" for="pmMinutes">Auto mute duration (minutes, 0 = permanent)</label>
<input id="pmMinutes" type="number" min="0" max="43200">
</div>
<div>
<label class="field-label" for="pmReason">Auto mute reason</label>
<input id="pmReason" type="text" maxlength="180">
</div>
<label class="checkbox-line"><input id="pmCancel" type="checkbox"> Cancel blocked message</label>
`;
document.getElementById('pmEnabled').checked = !!filter.enabled;
document.getElementById('pmWords').value = words;
document.getElementById('pmMinutes').value = Number.isFinite(Number(filter.autoMuteMinutes)) ? Number(filter.autoMuteMinutes) : 15;
document.getElementById('pmReason').value = filter.autoMuteReason || 'Inappropriate language';
document.getElementById('pmCancel').checked = filter.cancelMessage !== false;
}
function addGenericFieldRow(key, value, type) {
const list = document.getElementById('kvList');
if (!list) {
return;
}
const row = document.createElement('div');
row.className = 'kv-row';
row.innerHTML = `
<input class="kv-key" type="text" placeholder="setting.key" value="${escapeHtmlAttribute(key || '')}">
<input class="kv-value" type="text" placeholder="value" value="${escapeHtmlAttribute(value == null ? '' : String(value))}">
<select class="kv-type">
<option value="string">Text</option>
<option value="number">Number</option>
<option value="boolean">True/False</option>
</select>
<button class="secondary" type="button">Remove</button>
`;
row.querySelector('.kv-type').value = type || 'string';
row.querySelector('button').addEventListener('click', () => row.remove());
list.appendChild(row);
}
function renderGenericForm(settings) {
const form = document.getElementById('configForm');
form.innerHTML = `
<p class="hint">This extension does not have dedicated fields yet. Use key/value fields.</p>
<div id="kvList" class="kv-list"></div>
`;
const source = settings && typeof settings === 'object' ? settings : {};
const entries = Object.entries(source);
if (entries.length === 0) {
addGenericFieldRow('', '', 'string');
return;
}
for (const [key, value] of entries) {
if (typeof value === 'number') {
addGenericFieldRow(key, value, 'number');
continue;
}
if (typeof value === 'boolean') {
addGenericFieldRow(key, value ? 'true' : 'false', 'boolean');
continue;
}
if (typeof value === 'string') {
addGenericFieldRow(key, value, 'string');
continue;
}
addGenericFieldRow(key, JSON.stringify(value), 'string');
}
}
function renderSettingsForm(extensionId, settings) {
const addFieldBtn = document.getElementById('addFieldBtn');
if (extensionId === 'player-management') {
addFieldBtn.style.display = 'none';
renderPlayerManagementForm(settings);
return;
}
addFieldBtn.style.display = '';
renderGenericForm(settings);
}
async function selectExtension(extensionId) {
selectedExtensionId = extensionId || '';
renderExtensionList();
const editorTitle = document.getElementById('editorTitle');
const extension = findExtension(selectedExtensionId);
if (!extension) {
editorTitle.textContent = 'Settings';
selectedSettings = {};
clearConfigForm('Select an extension to configure.');
return;
}
editorTitle.textContent = `${extension.displayName || extension.id} Settings`;
const payload = await api(`/api/extensions/config/${encodeURIComponent(selectedExtensionId)}`);
selectedSettings = payload && payload.settings && typeof payload.settings === 'object' ? payload.settings : {};
renderSettingsForm(selectedExtensionId, selectedSettings);
}
async function loadExtensions() {
const payload = await api('/api/extensions/config');
extensions = Array.isArray(payload.extensions) ? payload.extensions : [];
if (!selectedExtensionId || !findExtension(selectedExtensionId)) {
selectedExtensionId = extensions.length > 0 ? (extensions[0].id || '') : '';
}
renderExtensionList();
if (selectedExtensionId) {
await selectExtension(selectedExtensionId);
} else {
selectedSettings = {};
clearConfigForm('No installed extensions found.');
document.getElementById('editorTitle').textContent = 'Settings';
}
}
function collectPlayerManagementSettings() {
const wordsRaw = document.getElementById('pmWords').value || '';
const words = wordsRaw
.split(/\r?\n/)
.map(x => x.trim())
.filter(Boolean);
const minutesRaw = Number(document.getElementById('pmMinutes').value || 0);
const minutes = Number.isFinite(minutesRaw) ? Math.max(0, Math.min(43200, Math.floor(minutesRaw))) : 15;
return {
badWordFilter: {
enabled: document.getElementById('pmEnabled').checked,
words,
autoMuteMinutes: minutes,
autoMuteReason: (document.getElementById('pmReason').value || 'Inappropriate language').trim() || 'Inappropriate language',
cancelMessage: document.getElementById('pmCancel').checked
}
};
}
function parseGenericValue(type, value) {
if (type === 'number') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
if (type === 'boolean') {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
return String(value == null ? '' : value);
}
function escapeHtmlAttribute(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function collectGenericSettings() {
const result = {};
document.querySelectorAll('#kvList .kv-row').forEach(row => {
const key = (row.querySelector('.kv-key').value || '').trim();
if (!key) {
return;
}
const type = row.querySelector('.kv-type').value || 'string';
const rawValue = row.querySelector('.kv-value').value || '';
result[key] = parseGenericValue(type, rawValue);
});
return result;
}
function collectSettingsFromForm() {
if (selectedExtensionId === 'player-management') {
return collectPlayerManagementSettings();
}
return collectGenericSettings();
}
async function saveCurrentSettings() {
if (!selectedExtensionId) {
setStatus('Select an extension first.', true);
return;
}
const settings = collectSettingsFromForm();
await api(`/api/extensions/config/${encodeURIComponent(selectedExtensionId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings })
});
selectedSettings = settings;
setStatus(`Saved settings for ${selectedExtensionId}.`, false);
}
document.getElementById('saveBtn').addEventListener('click', async () => {
try {
await saveCurrentSettings();
} catch (error) {
setStatus(error.message || 'Could not save extension settings.', true);
}
});
document.getElementById('reloadBtn').addEventListener('click', async () => {
try {
await loadExtensions();
setStatus('Reloaded extension settings.', false);
} catch (error) {
setStatus(error.message || 'Could not reload extension settings.', true);
}
});
document.getElementById('addFieldBtn').addEventListener('click', () => {
if (!selectedExtensionId) {
setStatus('Select an extension first.', true);
return;
}
if (selectedExtensionId === 'player-management') {
setStatus('Player management uses dedicated fields.', true);
return;
}
addGenericFieldRow('', '', 'string');
setStatus('Added new field row.', false);
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadExtensions();
} catch (error) {
if (error && error.status === 401) {
window.location.href = '/';
return;
}
setStatus(error && error.message ? error.message : 'Could not load extension config page.', true);
}
})();
</script>
</body>
</html>
@@ -0,0 +1,417 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Extensions</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
<style>
.ext-link {
color: var(--accent);
text-decoration: none;
font-weight: 600;
border-bottom: 1px solid transparent;
transition: color 0.15s ease, border-color 0.15s ease;
}
.ext-link:hover {
color: #9ec0ff;
border-bottom-color: #9ec0ff;
}
.ext-download {
display: inline-block;
padding: 6px 10px;
border-radius: 7px;
border: 1px solid var(--button-border);
background: var(--button-bg);
color: #ffffff;
text-decoration: none;
font-weight: 600;
font-size: 13px;
line-height: 1;
transition: background 0.15s ease;
}
.ext-download:hover {
background: var(--button-hover-bg);
}
.ext-status {
display: inline-block;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--table-row-border);
font-size: 12px;
font-weight: 700;
}
.ext-status-up {
color: #8af0b4;
background: rgba(46, 204, 113, 0.18);
border-color: rgba(46, 204, 113, 0.5);
}
.ext-status-outdated {
color: #ffe08b;
background: rgba(241, 196, 15, 0.18);
border-color: rgba(241, 196, 15, 0.5);
}
.ext-status-unknown {
color: #c7d1ea;
background: rgba(127, 140, 170, 0.18);
border-color: rgba(127, 140, 170, 0.5);
}
.ext-status-restart {
color: #9ec0ff;
background: rgba(81, 125, 255, 0.2);
border-color: rgba(81, 125, 255, 0.45);
}
.ext-status-installed {
color: #8af0b4;
background: rgba(46, 204, 113, 0.18);
border-color: rgba(46, 204, 113, 0.5);
}
.ext-warning {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(241, 196, 15, 0.45);
background: rgba(241, 196, 15, 0.14);
color: #ffe08b;
font-size: 13px;
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
<a class="side-link active" href="/dashboard/extensions">Extensions</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Extensions</h1>
<p>Shows loaded extensions and extension downloads from the latest GitHub release/pre-release.</p>
<div id="extensionsWarning" class="ext-warning" style="display:none;"></div>
<div id="extensionsInstallMessage" class="ext-warning" style="display:none;"></div>
<div class="toolbar">
<select id="releaseChannel" style="width:auto;min-width:180px;">
<option value="release">Release</option>
<option value="prerelease">Pre-release</option>
</select>
<button id="reloadExtensions" class="secondary">Reload Extensions</button>
<button id="refresh" class="secondary">Refresh</button>
</div>
<section class="card">
<h2>Installed Extensions (<span id="installedCount">0</span>)</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Source</th>
<th>Status</th>
</tr>
</thead>
<tbody id="installedBody"></tbody>
</table>
</section>
<section class="card spaced-top">
<h2>Available Extensions on GitHub (<span id="availableExtensionCount">0</span>)</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Release</th>
<th>Download</th>
</tr>
</thead>
<tbody id="availableExtensionsBody"></tbody>
</table>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const nextExpanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', nextExpanded);
toggle.classList.toggle('expanded', nextExpanded);
savedState[category] = nextExpanded;
persistState();
});
});
}
async function api(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Request failed');
}
return payload;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function renderInstalled(installed) {
const body = document.getElementById('installedBody');
body.innerHTML = '';
if (!Array.isArray(installed) || installed.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4">No extensions loaded</td>';
body.appendChild(row);
return;
}
for (const extension of installed) {
const row = document.createElement('tr');
const status = extension.status || 'unknown';
const statusText = extension.statusText || 'Unknown';
const statusClass = status === 'outdated'
? 'ext-status ext-status-outdated'
: (status === 'up_to_date' ? 'ext-status ext-status-up' : 'ext-status ext-status-unknown');
const statusMarkup = `<span class="${statusClass}">${statusText}</span>`;
row.innerHTML = `
<td>${extension.id}</td>
<td>${extension.displayName}</td>
<td>${extension.source}</td>
<td>${statusMarkup}</td>
`;
body.appendChild(row);
}
}
function installStateBadge(state) {
if (state === 'restart_required') {
return '<span class="ext-status ext-status-restart">Restart required</span>';
}
if (state === 'installed') {
return '<span class="ext-status ext-status-installed">Installed</span>';
}
return '';
}
function renderAvailableExtensions(availableExtensions) {
const body = document.getElementById('availableExtensionsBody');
body.innerHTML = '';
if (!Array.isArray(availableExtensions) || availableExtensions.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4">No MinePanel extension assets found on GitHub releases</td>';
body.appendChild(row);
return;
}
for (const extension of availableExtensions) {
const name = extension.name || extension.id || 'Unknown';
const description = extension.description || '-';
const release = extension.release || '-';
const projectUrl = extension.projectUrl || '#';
const extensionId = extension.extensionId || '';
const fileName = extension.id || '';
const installState = extension.installState || 'not_installed';
const prereleaseSuffix = extension.prerelease ? ' (pre-release)' : '';
const actionMarkup = installState === 'restart_required'
? '<span class="ext-status ext-status-restart">Restart required</span>'
: (installState === 'installed'
? '<span class="ext-status ext-status-installed">Installed</span>'
: `<button class="ext-download" data-install-extension="${extensionId}" data-install-file="${fileName}">Install</button>`);
const row = document.createElement('tr');
row.innerHTML = `
<td><a class="ext-link" href="${projectUrl}" target="_blank" rel="noopener noreferrer">${name}</a></td>
<td>${description}</td>
<td>${release}${prereleaseSuffix}</td>
<td>${actionMarkup}</td>
`;
body.appendChild(row);
}
body.querySelectorAll('button[data-install-extension]').forEach(button => {
button.addEventListener('click', async () => {
const channel = document.getElementById('releaseChannel').value || 'release';
const selectedExtensionId = button.dataset.installExtension || '';
const selectedFile = button.dataset.installFile || '';
await installExtension(channel, selectedExtensionId, selectedFile, button);
});
});
}
function showInstallMessage(message, isError) {
const banner = document.getElementById('extensionsInstallMessage');
banner.textContent = message || '';
banner.style.display = message ? 'block' : 'none';
if (isError) {
banner.style.borderColor = 'rgba(231, 76, 60, 0.45)';
banner.style.background = 'rgba(231, 76, 60, 0.16)';
banner.style.color = '#ffc7c1';
return;
}
banner.style.borderColor = 'rgba(81, 125, 255, 0.45)';
banner.style.background = 'rgba(81, 125, 255, 0.16)';
banner.style.color = '#bfd1ff';
}
async function installExtension(channel, extensionId, fileName, button) {
if (!extensionId || !fileName) {
showInstallMessage('Invalid extension selection.', true);
return;
}
const previousText = button.textContent;
button.disabled = true;
button.textContent = 'Installing...';
showInstallMessage('', false);
try {
const response = await api('/api/extensions/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, extensionId, fileName })
});
const text = response.message || 'Downloaded extension. Restart required.';
showInstallMessage(text, false);
await loadExtensionStatus();
} catch (error) {
showInstallMessage(error.message || 'Could not install extension.', true);
button.disabled = false;
button.textContent = previousText;
}
}
async function reloadExtensions(button) {
const previousText = button.textContent;
button.disabled = true;
button.textContent = 'Reloading...';
showInstallMessage('', false);
try {
const payload = await api('/api/extensions/reload', { method: 'POST' });
const warningText = Array.isArray(payload.warnings) && payload.warnings.length > 0
? ` Warnings: ${payload.warnings.join(', ')}`
: '';
showInstallMessage((payload.message || 'Extensions reloaded.') + warningText, false);
await loadExtensionStatus();
} catch (error) {
showInstallMessage(error.message || 'Could not reload extensions.', true);
} finally {
button.disabled = false;
button.textContent = previousText;
}
}
async function loadExtensionStatus() {
const channel = document.getElementById('releaseChannel').value || 'release';
const status = await api(`/api/extensions/status?channel=${encodeURIComponent(channel)}`);
if (status.channel) {
document.getElementById('releaseChannel').value = status.channel;
}
const warning = document.getElementById('extensionsWarning');
if (status.availableExtensionsWarning) {
warning.textContent = status.availableExtensionsWarning;
warning.style.display = 'block';
} else {
warning.textContent = '';
warning.style.display = 'none';
}
document.getElementById('installedCount').textContent = String(status.installedCount || 0);
document.getElementById('availableExtensionCount').textContent = String(status.availableExtensionCount || 0);
renderInstalled(status.installed);
renderAvailableExtensions(status.availableExtensions);
}
document.getElementById('releaseChannel').addEventListener('change', async () => {
await loadExtensionStatus();
});
document.getElementById('refresh').addEventListener('click', async () => {
showInstallMessage('', false);
await loadExtensionStatus();
});
document.getElementById('reloadExtensions').addEventListener('click', async () => {
const button = document.getElementById('reloadExtensions');
await reloadExtensions(button);
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadExtensionStatus();
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,349 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Maintenance</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
<style>
.maintenance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.maintenance-stat {
border: 1px solid var(--card-border);
background: var(--panel-bg);
border-radius: 10px;
padding: 12px;
}
.maintenance-stat-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted-text);
margin-bottom: 6px;
}
.maintenance-stat-value {
font-size: 16px;
font-weight: 700;
}
.maintenance-online {
color: #8af0b4;
}
.maintenance-offline {
color: #ff9ca1;
}
.maintenance-reason {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text);
padding: 10px 11px;
font-size: 14px;
font-family: inherit;
line-height: 1.4;
min-height: 84px;
resize: vertical;
}
.maintenance-reason:focus {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
.maintenance-motd {
margin-top: 8px;
}
.maintenance-note {
margin-top: 10px;
font-size: 13px;
color: var(--muted-text);
}
.maintenance-message {
margin-top: 12px;
padding: 9px 11px;
border-radius: 8px;
border: 1px solid var(--table-row-border);
background: var(--panel-bg);
display: none;
}
.maintenance-message.error {
border-color: rgba(231, 76, 60, 0.45);
background: rgba(231, 76, 60, 0.14);
color: #ffc7c1;
}
.maintenance-message.success {
border-color: rgba(46, 204, 113, 0.45);
background: rgba(46, 204, 113, 0.14);
color: #b9f8cf;
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
<a class="side-link" href="/dashboard/extensions">Extensions</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Maintenance</h1>
<p>Enable maintenance mode, block non-staff joins, and remove non-staff players currently online.</p>
<section class="card">
<h2>Status</h2>
<div class="maintenance-grid">
<div class="maintenance-stat">
<div class="maintenance-stat-title">State</div>
<div id="stateValue" class="maintenance-stat-value">Unknown</div>
</div>
<div class="maintenance-stat">
<div class="maintenance-stat-title">Affected Players</div>
<div id="affectedValue" class="maintenance-stat-value">0</div>
</div>
<div class="maintenance-stat">
<div class="maintenance-stat-title">Last Changed</div>
<div id="changedValue" class="maintenance-stat-value">-</div>
</div>
<div class="maintenance-stat">
<div class="maintenance-stat-title">Bypass Permission</div>
<div id="bypassValue" class="maintenance-stat-value">minepanel.maintenance.bypass</div>
</div>
</div>
<p class="maintenance-note">Operators and players with bypass permission can still join.</p>
</section>
<section class="card spaced-top">
<h2>Controls</h2>
<label for="reasonInput">Maintenance reason</label>
<textarea id="reasonInput" class="maintenance-reason" placeholder="Server maintenance in progress"></textarea>
<label class="maintenance-motd" for="motdInput">Maintenance MOTD</label>
<input id="motdInput" type="text" placeholder="Maintenance mode is active">
<div class="toolbar">
<button id="enableKickBtn">Enable + Kick Non-Staff</button>
<button id="enableBtn" class="secondary">Enable Only</button>
<button id="disableBtn" class="danger">Disable</button>
<button id="kickBtn" class="secondary">Kick Non-Staff Now</button>
<button id="refreshBtn" class="secondary">Refresh</button>
</div>
<div id="message" class="maintenance-message"></div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const nextExpanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', nextExpanded);
toggle.classList.toggle('expanded', nextExpanded);
savedState[category] = nextExpanded;
persistState();
});
});
}
async function api(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Request failed');
}
return payload;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function setMessage(message, kind) {
const messageElement = document.getElementById('message');
messageElement.textContent = message || '';
messageElement.className = 'maintenance-message';
if (!message) {
messageElement.style.display = 'none';
return;
}
if (kind === 'error') {
messageElement.classList.add('error');
} else {
messageElement.classList.add('success');
}
messageElement.style.display = 'block';
}
function applyStatus(status) {
const stateElement = document.getElementById('stateValue');
const enabled = status.enabled === true;
stateElement.textContent = enabled ? 'Enabled' : 'Disabled';
stateElement.classList.toggle('maintenance-online', enabled);
stateElement.classList.toggle('maintenance-offline', !enabled);
document.getElementById('affectedValue').textContent = String(status.affectedOnlinePlayers || 0);
document.getElementById('bypassValue').textContent = status.bypassPermission || 'minepanel.maintenance.bypass';
const changedBy = status.changedBy || 'SYSTEM';
const changedAt = Number(status.changedAt || 0);
const changedText = changedAt > 0 ? `${new Date(changedAt).toLocaleString()} by ${changedBy}` : `- by ${changedBy}`;
document.getElementById('changedValue').textContent = changedText;
const reasonInput = document.getElementById('reasonInput');
if (typeof status.reason === 'string' && status.reason.length > 0) {
reasonInput.value = status.reason;
}
const motdInput = document.getElementById('motdInput');
if (typeof status.motd === 'string' && status.motd.length > 0) {
motdInput.value = status.motd;
}
}
async function refreshStatus() {
const status = await api('/api/extensions/maintenance/status');
applyStatus(status);
}
async function toggleMaintenance(enable, kickNonStaff) {
const reason = document.getElementById('reasonInput').value.trim();
const motd = document.getElementById('motdInput').value.trim();
const endpoint = enable ? '/api/extensions/maintenance/enable' : '/api/extensions/maintenance/disable';
const body = enable ? { reason, motd, kickNonStaff: kickNonStaff === true } : {};
const result = await api(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
applyStatus(result);
const kickedPart = Number(result.kicked || 0) > 0 ? ` Kicked ${result.kicked} player(s).` : '';
setMessage(enable ? `Maintenance enabled.${kickedPart}` : 'Maintenance disabled.', 'success');
}
async function kickNonStaffNow() {
const result = await api('/api/extensions/maintenance/kick-nonstaff', { method: 'POST' });
applyStatus(result);
setMessage(`Kicked ${result.kicked || 0} player(s).`, 'success');
}
document.getElementById('enableKickBtn').addEventListener('click', async () => {
try {
await toggleMaintenance(true, true);
} catch (error) {
setMessage(error.message || 'Could not enable maintenance.', 'error');
}
});
document.getElementById('enableBtn').addEventListener('click', async () => {
try {
await toggleMaintenance(true, false);
} catch (error) {
setMessage(error.message || 'Could not enable maintenance.', 'error');
}
});
document.getElementById('disableBtn').addEventListener('click', async () => {
try {
await toggleMaintenance(false, false);
} catch (error) {
setMessage(error.message || 'Could not disable maintenance.', 'error');
}
});
document.getElementById('kickBtn').addEventListener('click', async () => {
try {
await kickNonStaffNow();
} catch (error) {
setMessage(error.message || 'Could not kick players.', 'error');
}
});
document.getElementById('refreshBtn').addEventListener('click', async () => {
try {
await refreshStatus();
setMessage('', 'success');
} catch (error) {
setMessage(error.message || 'Could not refresh status.', 'error');
}
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await refreshStatus();
setInterval(() => {
refreshStatus().catch(() => {});
}, 3000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,523 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Overview</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link active" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Overview</h1>
<section class="overview-top-grid spaced-top">
<div class="card stat-card">
<div class="stat-label">TPS <span id="tpsStatus" class="metric-status metric-unknown">Unavailable</span></div>
<div id="statTps" class="stat-value">-</div>
</div>
<div class="card stat-card">
<div class="stat-label">Memory Usage</div>
<div id="statMemory" class="stat-value">0 MB / 0 MB</div>
</div>
<div class="card stat-card">
<div class="stat-label">CPU Usage</div>
<div id="statCpu" class="stat-value">-</div>
</div>
</section>
<section class="card spaced-top">
<h2>TPS Over Time (Last Hour)</h2>
<div class="chart-wrapper chart-tall">
<canvas id="tpsChart"></canvas>
</div>
</section>
<section class="overview-bottom-grid spaced-top">
<div class="card">
<h2>Memory Over Time</h2>
<div class="chart-wrapper">
<canvas id="memoryChart"></canvas>
</div>
</div>
<div class="card">
<h2>CPU Over Time</h2>
<div class="chart-wrapper">
<canvas id="cpuChart"></canvas>
</div>
</div>
</section>
<section class="overview-bottom-grid spaced-top">
<div class="card">
<h2>Join Heatmap (Day x Hour)</h2>
<div class="chart-wrapper">
<canvas id="joinHeatmapChart"></canvas>
</div>
</div>
<div class="card">
<h2>Leave Heatmap (Day x Hour)</h2>
<div class="chart-wrapper">
<canvas id="leaveHeatmapChart"></canvas>
</div>
</div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
let latestHistory = { tps: [], memory: [], cpu: [] };
let latestHeatmaps = { join: [], leave: [], maxJoin: 0, maxLeave: 0 };
const heatmapRenderMeta = {};
const heatmapTooltip = createHeatmapTooltip();
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
async function loadOverview() {
const metrics = await api('/api/overview/metrics');
const current = metrics.current || {};
latestHistory = metrics.history || { tps: [], memory: [], cpu: [] };
latestHeatmaps = {
join: Array.isArray(metrics.joinHeatmap) ? metrics.joinHeatmap : [],
leave: Array.isArray(metrics.leaveHeatmap) ? metrics.leaveHeatmap : [],
maxJoin: Number(metrics.heatmapMaxJoin) || 0,
maxLeave: Number(metrics.heatmapMaxLeave) || 0
};
updateTopMetrics(current);
drawAllCharts();
}
function updateTopMetrics(current) {
const tps = Number(current.tps);
const memoryUsedMb = Number(current.memoryUsedMb);
const memoryMaxMb = Number(current.memoryMaxMb);
const cpuPercent = Number(current.cpuPercent);
if (Number.isFinite(tps) && tps >= 0) {
document.getElementById('statTps').textContent = tps.toFixed(2);
} else {
document.getElementById('statTps').textContent = '-';
}
document.getElementById('statMemory').textContent = Number.isFinite(memoryUsedMb) && Number.isFinite(memoryMaxMb) && memoryMaxMb > 0
? `${Math.max(0, memoryUsedMb).toFixed(0)} MB / ${Math.max(0, memoryMaxMb).toFixed(0)} MB`
: 'Unavailable';
document.getElementById('statCpu').textContent = Number.isFinite(cpuPercent) && cpuPercent >= 0
? `${cpuPercent.toFixed(1)}%`
: 'Unavailable';
const status = resolveTpsStatus(tps);
const statusElement = document.getElementById('tpsStatus');
statusElement.textContent = status.label;
statusElement.className = `metric-status ${status.cssClass}`;
}
function resolveTpsStatus(tps) {
if (!Number.isFinite(tps) || tps < 0) {
return { label: 'Unavailable', cssClass: 'metric-unknown' };
}
if (tps >= 18.0) {
return { label: 'Good', cssClass: 'metric-good' };
}
if (tps >= 15.0) {
return { label: 'Medium', cssClass: 'metric-medium' };
}
return { label: 'Bad', cssClass: 'metric-bad' };
}
function themeColor(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function drawAllCharts() {
drawSeriesChart('tpsChart', latestHistory.tps || [], {
minY: 0,
maxY: 20,
color: '#58d68d',
fill: 'rgba(88, 214, 141, 0.18)'
});
drawSeriesChart('memoryChart', latestHistory.memory || [], {
minY: 0,
maxY: 100,
color: '#5da5ff',
fill: 'rgba(93, 165, 255, 0.18)'
});
drawSeriesChart('cpuChart', latestHistory.cpu || [], {
minY: 0,
maxY: 100,
color: '#f6c85f',
fill: 'rgba(246, 200, 95, 0.18)'
});
drawHeatmap('joinHeatmapChart', latestHeatmaps.join, latestHeatmaps.maxJoin, '#58d68d');
drawHeatmap('leaveHeatmapChart', latestHeatmaps.leave, latestHeatmaps.maxLeave, '#ff8a8a');
}
function drawHeatmap(canvasId, cells, maxCount, accentColor) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
canvas.width = Math.max(1, Math.floor(width * dpr));
canvas.height = Math.max(1, Math.floor(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const chartBackground = themeColor('--chart-bg', '#0b1427');
const chartLabel = themeColor('--chart-label', '#8ea6d2');
const chartGrid = themeColor('--chart-grid', 'rgba(120, 150, 210, 0.22)');
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = chartBackground;
ctx.fillRect(0, 0, width, height);
const padding = { left: 48, right: 10, top: 10, bottom: 22 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
if (chartWidth <= 0 || chartHeight <= 0) {
return;
}
const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const cellWidth = chartWidth / 24;
const cellHeight = chartHeight / 7;
const maxValue = Math.max(1, Number(maxCount) || 0);
const byKey = new Map();
for (const cell of Array.isArray(cells) ? cells : []) {
const day = Number(cell.day);
const hour = Number(cell.hour);
const count = Number(cell.count);
if (!Number.isFinite(day) || !Number.isFinite(hour) || !Number.isFinite(count)) {
continue;
}
byKey.set(`${day}:${hour}`, Math.max(0, Math.floor(count)));
}
for (let day = 0; day < 7; day += 1) {
for (let hour = 0; hour < 24; hour += 1) {
const count = byKey.get(`${day}:${hour}`) || 0;
const normalized = count <= 0 ? 0 : Math.pow(Math.min(1, count / maxValue), 0.6);
const alpha = count <= 0 ? 0.03 : Math.min(1, 0.1 + normalized * 0.9);
const x = padding.left + hour * cellWidth;
const y = padding.top + day * cellHeight;
ctx.fillStyle = toRgba(accentColor, alpha);
ctx.fillRect(x + 1, y + 1, Math.max(1, cellWidth - 2), Math.max(1, cellHeight - 2));
}
}
ctx.strokeStyle = chartGrid;
ctx.lineWidth = 1;
for (let day = 0; day <= 7; day += 1) {
const y = padding.top + day * cellHeight;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(padding.left + chartWidth, y);
ctx.stroke();
}
for (let hour = 0; hour <= 24; hour += 1) {
const x = padding.left + hour * cellWidth;
ctx.beginPath();
ctx.moveTo(x, padding.top);
ctx.lineTo(x, padding.top + chartHeight);
ctx.stroke();
}
ctx.fillStyle = chartLabel;
ctx.font = '11px Segoe UI';
ctx.textBaseline = 'middle';
ctx.textAlign = 'right';
for (let day = 0; day < 7; day += 1) {
const y = padding.top + (day + 0.5) * cellHeight;
ctx.fillText(dayLabels[day], padding.left - 6, y);
}
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (let hour = 0; hour < 24; hour += 6) {
const x = padding.left + (hour + 0.5) * cellWidth;
ctx.fillText(`${hour}:00`, x, padding.top + chartHeight + 4);
}
heatmapRenderMeta[canvasId] = {
padding,
chartWidth,
chartHeight,
cellWidth,
cellHeight,
byKey,
dayLabels
};
}
function createHeatmapTooltip() {
const tooltip = document.createElement('div');
tooltip.style.position = 'fixed';
tooltip.style.zIndex = '1000';
tooltip.style.padding = '6px 8px';
tooltip.style.border = '1px solid var(--chart-border, #213457)';
tooltip.style.background = 'var(--surface, #111a2e)';
tooltip.style.color = 'var(--text, #e5ebf8)';
tooltip.style.borderRadius = '6px';
tooltip.style.font = '12px Segoe UI, sans-serif';
tooltip.style.pointerEvents = 'none';
tooltip.style.display = 'none';
document.body.appendChild(tooltip);
return tooltip;
}
function setupHeatmapHover(canvasId, mode) {
const canvas = document.getElementById(canvasId);
if (!canvas || canvas.dataset.hoverBound === '1') {
return;
}
canvas.dataset.hoverBound = '1';
canvas.addEventListener('mousemove', event => {
const meta = heatmapRenderMeta[canvasId];
if (!meta) {
heatmapTooltip.style.display = 'none';
return;
}
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const insideX = x >= meta.padding.left && x <= (meta.padding.left + meta.chartWidth);
const insideY = y >= meta.padding.top && y <= (meta.padding.top + meta.chartHeight);
if (!insideX || !insideY) {
heatmapTooltip.style.display = 'none';
return;
}
const hour = Math.max(0, Math.min(23, Math.floor((x - meta.padding.left) / meta.cellWidth)));
const day = Math.max(0, Math.min(6, Math.floor((y - meta.padding.top) / meta.cellHeight)));
const count = meta.byKey.get(`${day}:${hour}`) || 0;
const verb = mode === 'join' ? 'joined' : 'left';
const dayLabel = meta.dayLabels[day] || 'Unknown';
heatmapTooltip.textContent = `${dayLabel} ${hour}:00-${hour + 1}:00: ${count} ${verb}`;
heatmapTooltip.style.left = `${event.clientX + 12}px`;
heatmapTooltip.style.top = `${event.clientY + 12}px`;
heatmapTooltip.style.display = 'block';
});
canvas.addEventListener('mouseleave', () => {
heatmapTooltip.style.display = 'none';
});
}
function toRgba(hex, alpha) {
const normalized = (hex || '').trim();
const parsed = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(normalized);
if (!parsed) {
return `rgba(88, 140, 255, ${alpha})`;
}
const r = parseInt(parsed[1], 16);
const g = parseInt(parsed[2], 16);
const b = parseInt(parsed[3], 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function drawSeriesChart(canvasId, points, options) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
canvas.width = Math.max(1, Math.floor(width * dpr));
canvas.height = Math.max(1, Math.floor(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const chartBackground = themeColor('--chart-bg', '#0b1427');
const chartGrid = themeColor('--chart-grid', 'rgba(120, 150, 210, 0.22)');
const chartLabel = themeColor('--chart-label', '#8ea6d2');
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = chartBackground;
ctx.fillRect(0, 0, width, height);
const padding = { left: 40, right: 16, top: 12, bottom: 24 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
if (chartWidth <= 0 || chartHeight <= 0) {
return;
}
const minY = options.minY;
const maxY = options.maxY;
const safePoints = (Array.isArray(points) ? points : [])
.map(point => ({
timestamp: Number(point.timestamp),
value: Number(point.value)
}))
.filter(point => Number.isFinite(point.timestamp) && Number.isFinite(point.value) && point.value >= 0);
const now = Date.now();
const minX = now - (60 * 60 * 1000);
const maxX = now;
ctx.strokeStyle = chartGrid;
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i += 1) {
const y = padding.top + (chartHeight * i / 4);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
const labelValue = maxY - ((maxY - minY) * i / 4);
ctx.fillStyle = chartLabel;
ctx.font = '11px Segoe UI';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(labelValue.toFixed(0), padding.left - 6, y);
}
if (safePoints.length === 0) {
ctx.fillStyle = chartLabel;
ctx.font = '13px Segoe UI';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('No data yet', width / 2, height / 2);
return;
}
const toX = timestamp => padding.left + ((timestamp - minX) / (maxX - minX)) * chartWidth;
const toY = value => padding.top + ((maxY - value) / (maxY - minY)) * chartHeight;
ctx.beginPath();
safePoints.forEach((point, index) => {
const x = toX(Math.max(minX, Math.min(maxX, point.timestamp)));
const y = toY(Math.max(minY, Math.min(maxY, point.value)));
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.lineTo(toX(Math.max(minX, Math.min(maxX, safePoints[safePoints.length - 1].timestamp))), padding.top + chartHeight);
ctx.lineTo(toX(Math.max(minX, Math.min(maxX, safePoints[0].timestamp))), padding.top + chartHeight);
ctx.closePath();
ctx.fillStyle = options.fill;
ctx.fill();
ctx.beginPath();
safePoints.forEach((point, index) => {
const x = toX(Math.max(minX, Math.min(maxX, point.timestamp)));
const y = toY(Math.max(minY, Math.min(maxY, point.value)));
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.strokeStyle = options.color;
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = chartLabel;
ctx.font = '11px Segoe UI';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('1h ago', padding.left, height - 18);
ctx.textAlign = 'right';
ctx.fillText('now', width - padding.right, height - 18);
}
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
setupHeatmapHover('joinHeatmapChart', 'join');
setupHeatmapHover('leaveHeatmapChart', 'leave');
await Promise.all([loadMe(), loadOverview()]);
setInterval(() => {
loadOverview().catch(() => {});
}, 5000);
window.addEventListener('resize', () => {
drawAllCharts();
});
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,710 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Players</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link active" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Players</h1>
<p>All players that ever joined: <span id="playerCount">0</span></p>
<section class="players-layout spaced-top">
<div class="card">
<div class="card-head">
<h2>Player List</h2>
<input id="playerSearch" class="player-search" placeholder="Search player">
</div>
<ul id="playerDirectory" class="player-directory"></ul>
</div>
<div class="card profile-card">
<h2>Player Profile</h2>
<div id="profileEmpty" class="player-empty">Select a player from the list.</div>
<div id="profileContent" class="profile-pane" style="display:none;">
<div class="profile-header">
<img id="profileAvatar" class="profile-avatar" alt="Player skin face">
<div>
<div class="profile-name-row">
<span id="profileName" class="profile-name"></span>
<span id="profileStatusDot" class="status-dot"></span>
<span id="profileStatusText" class="profile-status"></span>
</div>
<div id="profileUuid" class="profile-uuid"></div>
<div id="profileMuteState" class="profile-uuid" style="margin-top:6px;">Mute: -</div>
</div>
</div>
<div class="profile-grid">
<div class="profile-field"><span>First joined</span><strong id="profileFirstJoined">-</strong></div>
<div class="profile-field"><span>Last seen</span><strong id="profileLastSeen">-</strong></div>
<div class="profile-field"><span>Total playtime</span><strong id="profilePlaytime">-</strong></div>
<div class="profile-field"><span>Total sessions</span><strong id="profileSessions">-</strong></div>
<div class="profile-field"><span>IP Address</span><strong id="profileIp">-</strong></div>
<div class="profile-field"><span>Country</span><strong id="profileCountry">-</strong></div>
</div>
<div id="playerStatsSection" class="card spaced-top" style="display:none;padding:12px;">
<h3 style="margin:0 0 8px;">Player Stats</h3>
<div class="profile-grid">
<div class="profile-field"><span>Kills</span><strong id="statsKills">-</strong></div>
<div class="profile-field"><span>Deaths</span><strong id="statsDeaths">-</strong></div>
<div class="profile-field"><span>Money</span><strong id="statsMoney">-</strong></div>
<div class="profile-field"><span>Economy Provider</span><strong id="statsEconomyProvider">-</strong></div>
</div>
</div>
<div id="luckPermsSection" class="card spaced-top" style="display:none;padding:12px;">
<h3 style="margin:0 0 8px;">LuckPerms</h3>
<div class="profile-grid">
<div class="profile-field"><span>Primary Group</span><strong id="lpPrimaryGroup">-</strong></div>
<div class="profile-field"><span>Prefix / Suffix</span><strong id="lpPrefixSuffix">-</strong></div>
</div>
<div class="profile-field spaced-top"><span>Groups</span><strong id="lpGroups">-</strong></div>
<div class="profile-field spaced-top"><span>Permissions</span><strong id="lpPermissionsMeta">-</strong></div>
<pre id="lpPermissions" class="log-rectangle" style="height:180px;margin-top:8px;"></pre>
</div>
<div id="playerManagementActions" class="spaced-top" style="display:none;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;">
<input id="actionReason" placeholder="Reason for action" style="grid-column:1 / -1;">
<input id="actionDuration" type="number" min="1" value="60" placeholder="Duration (minutes)">
<button id="kickButton" class="secondary">Kick</button>
<button id="tempBanButton">Temp Ban</button>
<button id="banButton">Ban</button>
<button id="unbanButton" class="secondary">Unban</button>
<button id="muteButton">Mute</button>
<button id="unmuteButton" class="secondary">Unmute</button>
</div>
<div id="airstrikeActions" class="spaced-top" style="display:none;grid-template-columns:180px minmax(0,1fr);gap:8px;align-items:center;">
<button id="airstrikeButton" class="danger-button">Airstrike</button>
<input id="airstrikeAmount" type="number" min="1" max="20000" step="1" value="5000" placeholder="TNT amount">
</div>
</div>
</div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
let allPlayers = [];
let selectedUuid = null;
let playerManagementAvailable = false;
let luckPermsAvailable = false;
let playerStatsAvailable = false;
let airstrikeAvailable = false;
let mePermissions = [];
let selectedProfileRequestId = 0;
const avatarCache = new Map();
function isCurrentProfileRequest(uuid, requestId) {
return selectedUuid === uuid && selectedProfileRequestId === requestId;
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
mePermissions = Array.isArray(data.user.permissions) ? data.user.permissions : [];
}
function hasPermission(permissionName) {
return mePermissions.includes(permissionName);
}
async function detectPlayerManagementExtension() {
try {
const response = await fetch('/api/extensions/player-management/mute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const data = await response.json();
playerManagementAvailable = data.error !== 'not_found';
} catch {
playerManagementAvailable = false;
}
const actions = document.getElementById('playerManagementActions');
actions.style.display = playerManagementAvailable ? 'grid' : 'none';
}
async function detectLuckPermsExtension() {
const section = document.getElementById('luckPermsSection');
luckPermsAvailable = false;
section.style.display = 'none';
try {
const probeUuid = selectedUuid || (allPlayers.length > 0 ? allPlayers[0].uuid : null);
if (!probeUuid) {
return;
}
const data = await api(`/api/extensions/luckperms/player/${encodeURIComponent(probeUuid)}`);
luckPermsAvailable = data.available === true;
} catch {
luckPermsAvailable = false;
}
section.style.display = luckPermsAvailable ? 'block' : 'none';
}
async function detectPlayerStatsExtension() {
const section = document.getElementById('playerStatsSection');
playerStatsAvailable = false;
section.style.display = 'none';
try {
const probeUuid = selectedUuid || (allPlayers.length > 0 ? allPlayers[0].uuid : null);
if (!probeUuid) {
return;
}
const data = await api(`/api/extensions/player-stats/player/${encodeURIComponent(probeUuid)}`);
playerStatsAvailable = data.available === true;
} catch {
playerStatsAvailable = false;
}
section.style.display = playerStatsAvailable ? 'block' : 'none';
}
async function detectAirstrikeExtension() {
const actions = document.getElementById('airstrikeActions');
airstrikeAvailable = false;
actions.style.display = 'none';
if (!hasPermission('MANAGE_AIRSTRIKE')) {
return;
}
try {
const response = await fetch('/api/extensions/airstrike/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const data = await response.json();
airstrikeAvailable = data.error !== 'not_found';
} catch {
airstrikeAvailable = false;
}
actions.style.display = airstrikeAvailable ? 'block' : 'none';
}
function formatDate(timestamp) {
if (!timestamp || Number(timestamp) <= 0) {
return '-';
}
return new Date(Number(timestamp)).toLocaleString();
}
function formatPlaytime(seconds) {
const safe = Math.max(0, Number(seconds) || 0);
const days = Math.floor(safe / 86400);
const hours = Math.floor((safe % 86400) / 3600);
const minutes = Math.floor((safe % 3600) / 60);
const sec = Math.floor(safe % 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0 || days > 0) parts.push(`${hours}h`);
if (minutes > 0 || hours > 0 || days > 0) parts.push(`${minutes}m`);
parts.push(`${sec}s`);
return parts.join(' ');
}
function applyAvatar(img, uuid, username, requestId) {
img.dataset.avatarUuid = uuid;
img.dataset.avatarRequestId = String(requestId);
delete img.dataset.fallbackTried;
const cached = avatarCache.get(uuid);
if (cached) {
img.onerror = null;
img.onload = null;
img.src = cached;
return;
}
img.referrerPolicy = 'no-referrer';
img.alt = `${username} skin face`;
function avatarRequestStillCurrent() {
return img.dataset.avatarUuid === uuid
&& img.dataset.avatarRequestId === String(requestId)
&& isCurrentProfileRequest(uuid, requestId);
}
img.src = `https://crafatar.com/avatars/${uuid}?size=48&overlay`;
img.onerror = () => {
if (!avatarRequestStillCurrent()) {
return;
}
if (!img.dataset.fallbackTried) {
img.dataset.fallbackTried = '1';
img.src = `https://mc-heads.net/avatar/${encodeURIComponent(username)}/48`;
return;
}
img.onerror = null;
img.onload = null;
img.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><rect width="48" height="48" fill="%23131f38"/><text x="24" y="30" text-anchor="middle" font-family="Arial" font-size="18" fill="%23a9bbdf">?</text></svg>';
};
img.onload = () => {
if (!avatarRequestStillCurrent()) {
return;
}
const currentSource = img.currentSrc || img.src || '';
if (currentSource.startsWith('http')) {
avatarCache.set(uuid, currentSource);
}
};
}
async function loadPlayerList() {
const data = await api('/api/players');
allPlayers = Array.isArray(data.allPlayers) ? data.allPlayers : [];
document.getElementById('playerCount').textContent = String(allPlayers.length);
renderPlayerDirectory();
if (selectedUuid) {
const stillExists = allPlayers.some(player => player.uuid === selectedUuid);
if (stillExists) {
await loadPlayerProfile(selectedUuid);
}
}
}
function renderPlayerDirectory() {
const search = document.getElementById('playerSearch').value.trim().toLowerCase();
const list = document.getElementById('playerDirectory');
list.innerHTML = '';
const filtered = allPlayers.filter(player => {
if (!search) return true;
return `${player.username} ${player.uuid}`.toLowerCase().includes(search);
});
if (filtered.length === 0) {
const empty = document.createElement('li');
empty.className = 'player-empty';
empty.textContent = 'No players found';
list.appendChild(empty);
return;
}
filtered.forEach(player => {
const item = document.createElement('li');
item.className = `directory-item${player.uuid === selectedUuid ? ' active' : ''}`;
const dot = document.createElement('span');
dot.className = `status-dot ${player.online ? 'online' : 'offline'}`;
const name = document.createElement('span');
name.className = 'directory-name';
name.textContent = player.username;
item.appendChild(dot);
item.appendChild(name);
item.addEventListener('click', async () => {
selectedUuid = player.uuid;
renderPlayerDirectory();
await loadPlayerProfile(player.uuid);
});
list.appendChild(item);
});
}
async function loadMuteState(uuid, requestId) {
const muteState = document.getElementById('profileMuteState');
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
if (!playerManagementAvailable) {
muteState.textContent = 'Mute: extension not installed';
return;
}
muteState.textContent = 'Mute: -';
try {
const data = await api(`/api/extensions/player-management/mute/${encodeURIComponent(uuid)}`);
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
if (!data.muted) {
muteState.textContent = 'Mute: not muted';
return;
}
if (data.expiresAt) {
muteState.textContent = `Mute: active until ${new Date(Number(data.expiresAt)).toLocaleString()} (${data.reason || 'No reason'})`;
} else {
muteState.textContent = `Mute: active permanently (${data.reason || 'No reason'})`;
}
} catch {
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
muteState.textContent = 'Mute: unavailable';
}
}
async function loadPlayerProfile(uuid) {
const requestId = ++selectedProfileRequestId;
const data = await api(`/api/players/profile/${encodeURIComponent(uuid)}`);
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
const profile = data.profile;
document.getElementById('profileEmpty').style.display = 'none';
document.getElementById('profileContent').style.display = 'block';
document.getElementById('profileName').textContent = profile.username || 'Unknown';
document.getElementById('profileUuid').textContent = profile.uuid || '-';
const statusDot = document.getElementById('profileStatusDot');
statusDot.className = `status-dot ${profile.online ? 'online' : 'offline'}`;
document.getElementById('profileStatusText').textContent = profile.online ? 'Online' : 'Offline';
document.getElementById('profileFirstJoined').textContent = formatDate(profile.firstJoined);
document.getElementById('profileLastSeen').textContent = formatDate(profile.lastSeen);
document.getElementById('profilePlaytime').textContent = formatPlaytime(profile.totalPlaytimeSeconds);
document.getElementById('profileSessions').textContent = String(profile.totalSessions ?? 0);
document.getElementById('profileIp').textContent = profile.lastIp || '-';
document.getElementById('profileCountry').textContent = profile.country || 'Unknown';
applyAvatar(document.getElementById('profileAvatar'), profile.uuid, profile.username, requestId);
await loadMuteState(profile.uuid, requestId);
await loadPlayerStats(profile.uuid, requestId);
await loadLuckPermsState(profile.uuid, requestId);
}
async function loadPlayerStats(uuid, requestId) {
const section = document.getElementById('playerStatsSection');
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
if (!playerStatsAvailable) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
document.getElementById('statsKills').textContent = '-';
document.getElementById('statsDeaths').textContent = '-';
document.getElementById('statsMoney').textContent = '-';
document.getElementById('statsEconomyProvider').textContent = '-';
try {
const data = await api(`/api/extensions/player-stats/player/${encodeURIComponent(uuid)}`);
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
if (!data.available) {
section.style.display = 'none';
return;
}
document.getElementById('statsKills').textContent = String(data.kills ?? 0);
document.getElementById('statsDeaths').textContent = String(data.deaths ?? 0);
if (data.economyAvailable) {
document.getElementById('statsMoney').textContent = data.balanceFormatted || '-';
document.getElementById('statsEconomyProvider').textContent = data.economyProvider || '-';
} else {
document.getElementById('statsMoney').textContent = 'Economy not available';
document.getElementById('statsEconomyProvider').textContent = '-';
}
} catch {
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
section.style.display = 'none';
}
}
async function loadLuckPermsState(uuid, requestId) {
const section = document.getElementById('luckPermsSection');
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
if (!luckPermsAvailable) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
document.getElementById('lpPrimaryGroup').textContent = '-';
document.getElementById('lpPrefixSuffix').textContent = '-';
document.getElementById('lpGroups').textContent = '-';
document.getElementById('lpPermissionsMeta').textContent = 'Loading...';
document.getElementById('lpPermissions').textContent = '';
try {
const data = await api(`/api/extensions/luckperms/player/${encodeURIComponent(uuid)}`);
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
if (!data.available) {
section.style.display = 'none';
return;
}
const groups = Array.isArray(data.groups) ? data.groups : [];
const permissions = Array.isArray(data.permissions) ? data.permissions : [];
document.getElementById('lpPrimaryGroup').textContent = data.primaryGroup || '-';
document.getElementById('lpPrefixSuffix').textContent = `${data.prefix || '-'} / ${data.suffix || '-'}`;
document.getElementById('lpGroups').textContent = groups.length > 0 ? groups.join(', ') : '-';
const totalPermissions = Number(data.permissionsCount) || permissions.length;
const truncated = data.permissionsTruncated === true;
document.getElementById('lpPermissionsMeta').textContent = truncated
? `${permissions.length} shown of ${totalPermissions}`
: `${totalPermissions} permission(s)`;
document.getElementById('lpPermissions').textContent = permissions.length > 0
? permissions.join('\n')
: 'No granted permissions';
} catch {
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
section.style.display = 'none';
}
}
function selectedPlayerName() {
if (!selectedUuid) return '';
const player = allPlayers.find(x => x.uuid === selectedUuid);
return player ? player.username : '';
}
function actionReason(defaultValue) {
const raw = document.getElementById('actionReason').value.trim();
return raw || defaultValue;
}
function actionDuration() {
const parsed = Number(document.getElementById('actionDuration').value);
if (!Number.isFinite(parsed)) return 60;
return Math.max(1, Math.min(43200, Math.round(parsed)));
}
function actionTntAmount() {
const parsed = Number(document.getElementById('airstrikeAmount').value);
if (!Number.isFinite(parsed)) return 5000;
return Math.max(1, Math.min(20000, Math.round(parsed)));
}
async function runPlayerAction(handler) {
if (!selectedUuid) {
alert('Select a player first.');
return;
}
await handler();
await loadPlayerList();
await loadPlayerProfile(selectedUuid);
}
document.getElementById('kickButton').addEventListener('click', async () => {
await runPlayerAction(async () => {
await api(`/api/players/${encodeURIComponent(selectedUuid)}/kick`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: actionReason('Kicked by panel moderator') })
});
});
});
document.getElementById('tempBanButton').addEventListener('click', async () => {
await runPlayerAction(async () => {
await api(`/api/players/${encodeURIComponent(selectedUuid)}/temp-ban`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: selectedPlayerName(),
durationMinutes: actionDuration(),
reason: actionReason('Temporarily banned by panel moderator')
})
});
});
});
document.getElementById('banButton').addEventListener('click', async () => {
await runPlayerAction(async () => {
await api('/api/players/ban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uuid: selectedUuid,
username: selectedPlayerName(),
reason: actionReason('Banned by panel moderator')
})
});
});
});
document.getElementById('unbanButton').addEventListener('click', async () => {
await runPlayerAction(async () => {
await api('/api/players/unban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: selectedUuid, username: selectedPlayerName() })
});
});
});
document.getElementById('muteButton').addEventListener('click', async () => {
await runPlayerAction(async () => {
await api('/api/extensions/player-management/mute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uuid: selectedUuid,
username: selectedPlayerName(),
durationMinutes: actionDuration(),
reason: actionReason('Muted by panel moderator')
})
});
});
});
document.getElementById('unmuteButton').addEventListener('click', async () => {
await runPlayerAction(async () => {
await api('/api/extensions/player-management/unmute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: selectedUuid, username: selectedPlayerName() })
});
});
});
document.getElementById('airstrikeButton').addEventListener('click', async () => {
if (!airstrikeAvailable) {
alert('Airstrike extension is not available.');
return;
}
await runPlayerAction(async () => {
await api('/api/extensions/airstrike/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uuid: selectedUuid,
username: selectedPlayerName(),
amount: actionTntAmount()
})
});
});
});
document.getElementById('playerSearch').addEventListener('input', renderPlayerDirectory);
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await Promise.all([loadMe(), detectPlayerManagementExtension(), loadPlayerList()]);
await detectPlayerStatsExtension();
await detectLuckPermsExtension();
await detectAirstrikeExtension();
if (allPlayers.length > 0) {
selectedUuid = allPlayers[0].uuid;
renderPlayerDirectory();
await loadPlayerProfile(selectedUuid);
}
setInterval(() => {
loadPlayerList().catch(() => {});
}, 5000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,368 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Plugins</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link active" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Plugins</h1>
<p>Installed server plugins: <span id="pluginCount">0</span></p>
<div class="tabs">
<button id="tabInstalled" class="tab-link active" type="button">Installed</button>
<button id="tabInstall" class="tab-link" type="button">Install</button>
</div>
<section id="installedSection" class="card spaced-top">
<h2>Installed Plugins</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Version</th>
<th>Enabled</th>
<th>Main Class</th>
<th>Authors</th>
</tr>
</thead>
<tbody id="plugins"></tbody>
</table>
</div>
</section>
<section id="installSection" class="card spaced-top" style="display:none;">
<h2>Install From Marketplace</h2>
<p>Search results are filtered to Paper-compatible plugins.</p>
<div class="marketplace-search-row">
<select id="marketSource">
<option value="modrinth">Modrinth</option>
<option value="curseforge">CurseForge</option>
<option value="hangar">Hangar</option>
</select>
<input id="marketQuery" placeholder="Search Paper plugin name">
<button id="searchMarketplace" class="secondary" type="button">Search</button>
</div>
<div class="table-wrap spaced-top">
<table>
<thead>
<tr>
<th>Name</th>
<th>Author</th>
<th>Description</th>
<th>Versions</th>
<th>Install</th>
</tr>
</thead>
<tbody id="marketResults"></tbody>
</table>
</div>
<p id="marketStatus" class="action-status"></p>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
const projectVersions = new Map();
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function renderPluginRow(plugin) {
const tr = document.createElement('tr');
const nameTd = document.createElement('td');
nameTd.textContent = plugin.name || 'Unknown';
tr.appendChild(nameTd);
const versionTd = document.createElement('td');
versionTd.textContent = plugin.version || '-';
tr.appendChild(versionTd);
const enabledTd = document.createElement('td');
enabledTd.textContent = plugin.enabled ? 'Yes' : 'No';
tr.appendChild(enabledTd);
const mainTd = document.createElement('td');
mainTd.textContent = plugin.main || '-';
tr.appendChild(mainTd);
const authorsTd = document.createElement('td');
const authors = Array.isArray(plugin.authors) ? plugin.authors : [];
authorsTd.textContent = authors.length > 0 ? authors.join(', ') : '-';
tr.appendChild(authorsTd);
return tr;
}
async function loadPlugins() {
const data = await api('/api/plugins');
document.getElementById('pluginCount').textContent = data.count ?? 0;
const tbody = document.getElementById('plugins');
tbody.innerHTML = '';
if (!Array.isArray(data.plugins) || data.plugins.length === 0) {
const emptyRow = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 5;
cell.textContent = 'No plugins found';
emptyRow.appendChild(cell);
tbody.appendChild(emptyRow);
return;
}
data.plugins.forEach(plugin => {
tbody.appendChild(renderPluginRow(plugin));
});
}
function setTab(installedVisible) {
document.getElementById('installedSection').style.display = installedVisible ? 'block' : 'none';
document.getElementById('installSection').style.display = installedVisible ? 'none' : 'block';
document.getElementById('tabInstalled').classList.toggle('active', installedVisible);
document.getElementById('tabInstall').classList.toggle('active', !installedVisible);
}
function setMarketStatus(message, isError) {
const status = document.getElementById('marketStatus');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function fetchVersionsForProject(source, projectId) {
const key = `${source}:${projectId}`;
if (projectVersions.has(key)) {
return projectVersions.get(key);
}
const data = await api(`/api/plugin-marketplace/versions?source=${encodeURIComponent(source)}&projectId=${encodeURIComponent(projectId)}`);
const versions = Array.isArray(data.versions) ? data.versions : [];
projectVersions.set(key, versions);
return versions;
}
function createMarketRow(source, project) {
const tr = document.createElement('tr');
const nameTd = document.createElement('td');
nameTd.textContent = project.name || 'Unknown';
tr.appendChild(nameTd);
const authorTd = document.createElement('td');
authorTd.textContent = project.author || '-';
tr.appendChild(authorTd);
const descTd = document.createElement('td');
descTd.textContent = project.description || '-';
tr.appendChild(descTd);
const versionsTd = document.createElement('td');
const versionSelect = document.createElement('select');
versionSelect.disabled = true;
versionsTd.appendChild(versionSelect);
tr.appendChild(versionsTd);
const installTd = document.createElement('td');
const installBtn = document.createElement('button');
installBtn.textContent = 'Install';
installBtn.disabled = true;
installTd.appendChild(installBtn);
tr.appendChild(installTd);
(async () => {
try {
const versions = await fetchVersionsForProject(source, project.projectId);
versionSelect.innerHTML = '';
if (versions.length === 0) {
const option = document.createElement('option');
option.textContent = 'No versions';
versionSelect.appendChild(option);
return;
}
versions.forEach(version => {
const option = document.createElement('option');
option.value = version.versionId;
option.textContent = version.name || version.versionId;
versionSelect.appendChild(option);
});
versionSelect.disabled = false;
installBtn.disabled = false;
} catch (error) {
setMarketStatus(`Failed to load versions for ${project.name}: ${error.message}`, true);
}
})();
installBtn.addEventListener('click', async () => {
if (!versionSelect.value) {
return;
}
try {
installBtn.disabled = true;
const result = await api('/api/plugin-marketplace/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source,
projectId: project.projectId,
versionId: versionSelect.value
})
});
setMarketStatus(`Installed ${result.fileName}. Restart required to load the plugin.`, false);
await loadPlugins();
} catch (error) {
setMarketStatus(`Install failed: ${error.message}`, true);
} finally {
installBtn.disabled = false;
}
});
return tr;
}
async function searchMarketplace() {
const source = document.getElementById('marketSource').value;
const query = document.getElementById('marketQuery').value.trim();
if (!query) {
setMarketStatus('Enter a search term first', true);
return;
}
const data = await api(`/api/plugin-marketplace/search?source=${encodeURIComponent(source)}&query=${encodeURIComponent(query)}`);
const results = Array.isArray(data.results) ? data.results : [];
const tbody = document.getElementById('marketResults');
tbody.innerHTML = '';
if (results.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 5;
cell.textContent = 'No marketplace results found';
row.appendChild(cell);
tbody.appendChild(row);
setMarketStatus('', false);
return;
}
results.forEach(project => {
tbody.appendChild(createMarketRow(source, project));
});
setMarketStatus(`Found ${results.length} result(s) in ${source}.`, false);
}
document.getElementById('tabInstalled').addEventListener('click', () => setTab(true));
document.getElementById('tabInstall').addEventListener('click', () => setTab(false));
document.getElementById('searchMarketplace').addEventListener('click', async () => {
try {
await searchMarketplace();
} catch (error) {
setMarketStatus(`Search failed: ${error.message}`, true);
}
});
document.getElementById('marketQuery').addEventListener('keydown', async event => {
if (event.key === 'Enter') {
event.preventDefault();
try {
await searchMarketplace();
} catch (error) {
setMarketStatus(`Search failed: ${error.message}`, true);
}
}
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await Promise.all([loadMe(), loadPlugins()]);
setInterval(() => {
loadPlugins().catch(() => {});
}, 5000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,205 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Reports</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
<a class="side-link active" href="/dashboard/reports">Reports</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Reports</h1>
<div class="toolbar">
<select id="reportStatusFilter" style="width:auto;min-width:180px;">
<option value="">All statuses</option>
<option value="OPEN">Open</option>
<option value="RESOLVED">Resolved</option>
</select>
<button id="refreshReports" class="secondary">Refresh</button>
</div>
<section class="card">
<h2>Player Reports</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Reporter</th>
<th>Suspect</th>
<th>Reason</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="reportsBody"></tbody>
</table>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
function formatDate(timestamp) {
if (!timestamp || Number(timestamp) <= 0) return '-';
return new Date(Number(timestamp)).toLocaleString();
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
async function loadReports() {
const status = document.getElementById('reportStatusFilter').value;
const url = status ? `/api/extensions/reports?status=${encodeURIComponent(status)}` : '/api/extensions/reports';
const data = await api(url);
const body = document.getElementById('reportsBody');
body.innerHTML = '';
if (!Array.isArray(data.reports) || data.reports.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 7;
cell.textContent = 'No reports found';
row.appendChild(cell);
body.appendChild(row);
return;
}
data.reports.forEach(report => {
const row = document.createElement('tr');
row.innerHTML = `
<td>#${report.id}</td>
<td>${report.reporterName}</td>
<td>${report.suspectName}</td>
<td>${report.reason}</td>
<td>${report.status}</td>
<td>${formatDate(report.createdAt)}</td>
<td></td>
`;
const actionsCell = row.lastElementChild;
if (report.status === 'OPEN') {
const resolveButton = document.createElement('button');
resolveButton.className = 'secondary';
resolveButton.style.width = 'auto';
resolveButton.textContent = 'Resolve';
resolveButton.addEventListener('click', async () => {
await api(`/api/extensions/reports/${report.id}/resolve`, { method: 'POST' });
await loadReports();
});
const banButton = document.createElement('button');
banButton.style.width = 'auto';
banButton.textContent = 'Ban 60m';
banButton.addEventListener('click', async () => {
await api(`/api/extensions/reports/${report.id}/ban`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ durationMinutes: 60, reason: `Auto-ban from report #${report.id}` })
});
await loadReports();
});
actionsCell.appendChild(resolveButton);
actionsCell.appendChild(document.createTextNode(' '));
actionsCell.appendChild(banButton);
} else {
actionsCell.textContent = '-';
}
body.appendChild(row);
});
}
document.getElementById('refreshReports').addEventListener('click', loadReports);
document.getElementById('reportStatusFilter').addEventListener('change', loadReports);
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadReports();
setInterval(() => loadReports().catch(() => {}), 5000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,172 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Resources</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link active" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Resources</h1>
<section class="stats-grid spaced-top">
<div class="card stat-card">
<div class="stat-label">CPU Load (System)</div>
<div id="statCpuSystem" class="stat-value">0%</div>
</div>
<div class="card stat-card">
<div class="stat-label">CPU Load (MinePanel Process)</div>
<div id="statCpuProcess" class="stat-value">0%</div>
</div>
<div class="card stat-card">
<div class="stat-label">Memory Used</div>
<div id="statMemory" class="stat-value">0 MB</div>
</div>
<div class="card stat-card">
<div class="stat-label">Disk Used</div>
<div id="statDisk" class="stat-value">0 GB</div>
</div>
</section>
<section class="card spaced-top">
<h2>Server Runtime</h2>
<pre id="runtimeInfo" class="log-rectangle short"></pre>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function toPercent(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return 'Unavailable';
}
return `${Math.round(numeric * 1000) / 10}%`;
}
function toMb(bytes) {
return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
}
function toGb(bytes) {
return `${Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100} GB`;
}
async function loadHealth() {
const health = await api('/api/health');
document.getElementById('statCpuSystem').textContent = toPercent(health.cpuSystemLoad);
document.getElementById('statCpuProcess').textContent = toPercent(health.cpuProcessLoad);
document.getElementById('statMemory').textContent = `${toMb(health.memoryUsed || 0)} / ${toMb(health.memoryMax || 0)}`;
if ((health.diskUsed || -1) >= 0 && (health.diskTotal || -1) >= 0) {
document.getElementById('statDisk').textContent = `${toGb(health.diskUsed)} / ${toGb(health.diskTotal)}`;
} else {
document.getElementById('statDisk').textContent = 'Unavailable';
}
document.getElementById('runtimeInfo').textContent = [
`Server: ${health.serverName || 'unknown'}`,
`Version: ${health.serverVersion || 'unknown'}`,
`Bukkit: ${health.bukkitVersion || 'unknown'}`,
`CPU Cores: ${health.cpuCores || 0}`,
`Memory Total: ${toMb(health.memoryTotal || 0)}`,
`Memory Free: ${toMb((health.memoryTotal || 0) - (health.memoryUsed || 0))}`,
`Disk Free: ${(health.diskFree || -1) >= 0 ? toGb(health.diskFree) : 'Unavailable'}`
].join('\n');
}
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await Promise.all([loadMe(), loadHealth()]);
setInterval(() => {
loadHealth().catch(() => {});
}, 3000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,583 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Themes</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link active" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Themes</h1>
<p>Pick a preset or customize panel colors.</p>
<section class="card spaced-top">
<h2>Presets</h2>
<div class="theme-preset-row">
<select id="themePreset">
<option value="default">Default (MinePanel)</option>
<option value="midnight">Midnight Blue</option>
<option value="forest">Forest Dark</option>
<option value="ember">Ember Dark</option>
</select>
<button id="applyPreset" type="button">Apply Preset</button>
</div>
</section>
<section class="card spaced-top">
<h2>Custom Colors</h2>
<div id="themeGrid" class="theme-grid"></div>
<div class="theme-actions">
<button id="saveTheme" type="button">Save Custom Theme</button>
<button id="resetTheme" class="secondary" type="button">Reset to Default</button>
</div>
<p id="themeStatus" class="action-status"></p>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
const STORAGE_KEY = window.MinePanelTheme ? window.MinePanelTheme.storageKey : 'minepanel.customTheme';
const THEME_KEYS = [
'--bg',
'--surface',
'--surface-2',
'--border',
'--text',
'--text-muted',
'--accent',
'--accent-strong',
'--danger-bg',
'--danger-text',
'--log-bg',
'--sidebar-bg',
'--sidebar-border',
'--sidebar-meta-bg',
'--sidebar-meta-border',
'--sidebar-toggle-text',
'--sidebar-toggle-icon',
'--sidebar-link-text',
'--sidebar-link-bg',
'--sidebar-link-border',
'--sidebar-link-hover-bg',
'--button-bg',
'--button-border',
'--button-hover-bg',
'--button-secondary-bg',
'--button-secondary-border',
'--input-bg',
'--focus-ring',
'--table-bg',
'--table-head-bg',
'--table-row-border',
'--table-row-hover-bg',
'--table-head-text',
'--table-cell-text',
'--log-border',
'--log-text',
'--chart-bg',
'--chart-border',
'--chart-grid',
'--chart-label',
'--player-box-bg',
'--player-box-border',
'--player-box-hover-bg',
'--player-box-active-bg',
'--player-box-active-border',
'--player-muted-bg',
'--player-muted-border'
];
const THEME_GROUPS = [
{
title: 'Core',
keys: [
'--bg',
'--surface',
'--surface-2',
'--border',
'--text',
'--text-muted',
'--accent',
'--accent-strong',
'--danger-bg',
'--danger-text'
]
},
{
title: 'Sidebar',
keys: [
'--sidebar-bg',
'--sidebar-border',
'--sidebar-meta-bg',
'--sidebar-meta-border',
'--sidebar-toggle-text',
'--sidebar-toggle-icon',
'--sidebar-link-text',
'--sidebar-link-bg',
'--sidebar-link-border',
'--sidebar-link-hover-bg'
]
},
{
title: 'Buttons and Inputs',
keys: [
'--button-bg',
'--button-border',
'--button-hover-bg',
'--button-secondary-bg',
'--button-secondary-border',
'--input-bg',
'--focus-ring'
]
},
{
title: 'Logs and Tables',
keys: [
'--log-bg',
'--log-border',
'--log-text',
'--table-bg',
'--table-head-bg',
'--table-row-border',
'--table-row-hover-bg',
'--table-head-text',
'--table-cell-text'
]
},
{
title: 'Charts',
keys: [
'--chart-bg',
'--chart-border',
'--chart-grid',
'--chart-label'
]
},
{
title: 'Players',
keys: [
'--player-box-bg',
'--player-box-border',
'--player-box-hover-bg',
'--player-box-active-bg',
'--player-box-active-border',
'--player-muted-bg',
'--player-muted-border'
]
}
];
const PRESETS = {
default: {
'--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'
},
midnight: {
'--bg': '#070b17',
'--surface': '#0f1a30',
'--surface-2': '#132445',
'--border': '#263f67',
'--text': '#e6f0ff',
'--text-muted': '#97abd2',
'--accent': '#4f8cff',
'--accent-strong': '#2c6fe2',
'--danger-bg': '#3b1a2f',
'--danger-text': '#ffb5c6',
'--log-bg': '#090f1d',
'--sidebar-bg': '#050915',
'--sidebar-border': '#1b3158',
'--sidebar-meta-bg': '#0c1730',
'--sidebar-meta-border': '#1f3a67',
'--sidebar-toggle-text': '#8aa1ca',
'--sidebar-toggle-icon': '#9cb4e2',
'--sidebar-link-text': '#d2e2ff',
'--sidebar-link-bg': '#11203b',
'--sidebar-link-border': '#2b4773',
'--sidebar-link-hover-bg': '#183055',
'--button-bg': '#4f8cff',
'--button-border': '#2c6fe2',
'--button-hover-bg': '#5f98ff',
'--button-secondary-bg': '#23385e',
'--button-secondary-border': '#35507e',
'--input-bg': '#11203b',
'--focus-ring': '#4f8cff',
'--table-bg': '#0f1a31',
'--table-head-bg': '#13274a',
'--table-row-border': '#2a4773',
'--table-row-hover-bg': '#183157',
'--table-head-text': '#d7e6ff',
'--table-cell-text': '#cddcff',
'--log-border': '#2b4a78',
'--log-text': '#d4e3ff',
'--chart-bg': '#0f1d38',
'--chart-border': '#2b4a78',
'--chart-grid': '#87a7df',
'--chart-label': '#a3b8df',
'--player-box-bg': '#11203b',
'--player-box-border': '#2b4773',
'--player-box-hover-bg': '#183055',
'--player-box-active-bg': '#223c69',
'--player-box-active-border': '#4b82de',
'--player-muted-bg': '#0f1b34',
'--player-muted-border': '#2a4268'
},
forest: {
'--bg': '#0b1612',
'--surface': '#11231b',
'--surface-2': '#173026',
'--border': '#29503f',
'--text': '#e7fff5',
'--text-muted': '#97c7b0',
'--accent': '#3fbf7f',
'--accent-strong': '#2ca866',
'--danger-bg': '#3d1d29',
'--danger-text': '#ffb7c7',
'--log-bg': '#0b1511',
'--sidebar-bg': '#08130f',
'--sidebar-border': '#1f3f33',
'--sidebar-meta-bg': '#10231c',
'--sidebar-meta-border': '#2a4f40',
'--sidebar-toggle-text': '#8db8a3',
'--sidebar-toggle-icon': '#9fceb8',
'--sidebar-link-text': '#d6f5e7',
'--sidebar-link-bg': '#133026',
'--sidebar-link-border': '#2f5f4b',
'--sidebar-link-hover-bg': '#1a3d31',
'--button-bg': '#3fbf7f',
'--button-border': '#2ca866',
'--button-hover-bg': '#4dce8d',
'--button-secondary-bg': '#24493b',
'--button-secondary-border': '#356652',
'--input-bg': '#133026',
'--focus-ring': '#3fbf7f',
'--table-bg': '#11271f',
'--table-head-bg': '#173629',
'--table-row-border': '#2e5b48',
'--table-row-hover-bg': '#1a3f31',
'--table-head-text': '#daf5e8',
'--table-cell-text': '#c8ebda',
'--log-border': '#2e5a49',
'--log-text': '#d9f4e8',
'--chart-bg': '#10261e',
'--chart-border': '#2e5a49',
'--chart-grid': '#7fb6a1',
'--chart-label': '#94c7b3',
'--player-box-bg': '#133026',
'--player-box-border': '#2f5f4b',
'--player-box-hover-bg': '#1a3d31',
'--player-box-active-bg': '#23503f',
'--player-box-active-border': '#43a379',
'--player-muted-bg': '#11271f',
'--player-muted-border': '#2c5342'
},
ember: {
'--bg': '#18100b',
'--surface': '#2a1a11',
'--surface-2': '#3a2416',
'--border': '#5a3a23',
'--text': '#fff0e8',
'--text-muted': '#d6b8a4',
'--accent': '#ff8f4f',
'--accent-strong': '#e67839',
'--danger-bg': '#4a1f22',
'--danger-text': '#ffb7b7',
'--log-bg': '#1a120d',
'--sidebar-bg': '#140d08',
'--sidebar-border': '#4c311f',
'--sidebar-meta-bg': '#23160e',
'--sidebar-meta-border': '#5e3b26',
'--sidebar-toggle-text': '#d1a78f',
'--sidebar-toggle-icon': '#e0b69e',
'--sidebar-link-text': '#ffe4d2',
'--sidebar-link-bg': '#382116',
'--sidebar-link-border': '#6f4228',
'--sidebar-link-hover-bg': '#4a2c1d',
'--button-bg': '#ff8f4f',
'--button-border': '#e67839',
'--button-hover-bg': '#ffa366',
'--button-secondary-bg': '#5a3622',
'--button-secondary-border': '#7a4a30',
'--input-bg': '#382116',
'--focus-ring': '#ff8f4f',
'--table-bg': '#2f1d13',
'--table-head-bg': '#432719',
'--table-row-border': '#6b4027',
'--table-row-hover-bg': '#4b2e1f',
'--table-head-text': '#ffe7da',
'--table-cell-text': '#f2d2bf',
'--log-border': '#70442a',
'--log-text': '#ffe4d5',
'--chart-bg': '#341f14',
'--chart-border': '#70442a',
'--chart-grid': '#c18e71',
'--chart-label': '#d2a98f',
'--player-box-bg': '#382116',
'--player-box-border': '#6f4228',
'--player-box-hover-bg': '#4a2c1d',
'--player-box-active-bg': '#603924',
'--player-box-active-border': '#ff9a63',
'--player-muted-bg': '#311d12',
'--player-muted-border': '#5b3521'
}
};
function setStatus(message, isError) {
const status = document.getElementById('themeStatus');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
function currentValueForKey(key) {
const value = getComputedStyle(document.documentElement).getPropertyValue(key).trim();
return value || PRESETS.default[key] || '#000000';
}
function normalizeHex(value) {
if (!value) return '#000000';
const trimmed = value.trim();
if (!trimmed.startsWith('#')) return '#000000';
if (trimmed.length === 7) return trimmed;
if (trimmed.length === 4) {
return `#${trimmed[1]}${trimmed[1]}${trimmed[2]}${trimmed[2]}${trimmed[3]}${trimmed[3]}`;
}
return '#000000';
}
function buildThemeField(key) {
const row = document.createElement('label');
row.className = 'theme-field';
row.setAttribute('for', `theme-${key.replace(/[^a-z0-9]/gi, '')}`);
const name = document.createElement('span');
name.textContent = key;
const input = document.createElement('input');
input.type = 'color';
input.id = `theme-${key.replace(/[^a-z0-9]/gi, '')}`;
input.dataset.themeKey = key;
input.value = normalizeHex(currentValueForKey(key));
input.addEventListener('input', () => {
document.documentElement.style.setProperty(key, input.value);
});
row.appendChild(name);
row.appendChild(input);
return row;
}
function buildThemeGroup(title, keys) {
const section = document.createElement('section');
section.className = 'theme-group';
const heading = document.createElement('h3');
heading.className = 'theme-group-title';
heading.textContent = title;
const content = document.createElement('div');
content.className = 'theme-group-grid';
keys.forEach(key => {
content.appendChild(buildThemeField(key));
});
section.appendChild(heading);
section.appendChild(content);
return section;
}
function buildThemeGrid() {
const grid = document.getElementById('themeGrid');
grid.innerHTML = '';
const usedKeys = new Set();
THEME_GROUPS.forEach(group => {
const validKeys = group.keys.filter(key => THEME_KEYS.includes(key));
if (validKeys.length === 0) {
return;
}
validKeys.forEach(key => usedKeys.add(key));
grid.appendChild(buildThemeGroup(group.title, validKeys));
});
const ungrouped = THEME_KEYS.filter(key => !usedKeys.has(key));
if (ungrouped.length > 0) {
grid.appendChild(buildThemeGroup('Other', ungrouped));
}
}
function collectThemeFromInputs() {
const theme = {};
document.querySelectorAll('input[data-theme-key]').forEach(input => {
theme[input.dataset.themeKey] = input.value;
});
return theme;
}
function applyTheme(theme) {
if (window.MinePanelTheme) {
window.MinePanelTheme.applyTheme(theme);
} else {
Object.entries(theme).forEach(([key, value]) => {
document.documentElement.style.setProperty(key, value);
});
}
buildThemeGrid();
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
document.getElementById('applyPreset').addEventListener('click', () => {
const preset = document.getElementById('themePreset').value;
const theme = PRESETS[preset] || PRESETS.default;
applyTheme(theme);
setStatus(`Applied ${preset} preset. Save to keep it.`, false);
});
document.getElementById('saveTheme').addEventListener('click', () => {
const theme = collectThemeFromInputs();
localStorage.setItem(STORAGE_KEY, JSON.stringify(theme));
setStatus('Custom theme saved.', false);
});
document.getElementById('resetTheme').addEventListener('click', () => {
localStorage.removeItem(STORAGE_KEY);
applyTheme(PRESETS.default);
setStatus('Theme reset to default.', false);
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
buildThemeGrid();
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,200 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Tickets</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
<a class="side-link" href="/dashboard/reports">Reports</a>
<a class="side-link active" href="/dashboard/tickets">Tickets</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Tickets</h1>
<div class="toolbar">
<select id="ticketStatusFilter" style="width:auto;min-width:180px;">
<option value="">All statuses</option>
<option value="OPEN">Open</option>
<option value="CLOSED">Closed</option>
</select>
<button id="refreshTickets" class="secondary">Refresh</button>
</div>
<section class="card">
<h2>Player Tickets</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Player</th>
<th>Category</th>
<th>Description</th>
<th>Status</th>
<th>Created</th>
<th>Handled By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="ticketsBody"></tbody>
</table>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
function formatDate(timestamp) {
if (!timestamp || Number(timestamp) <= 0) return '-';
return new Date(Number(timestamp)).toLocaleString();
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
async function loadTickets() {
const status = document.getElementById('ticketStatusFilter').value;
const url = status ? `/api/extensions/tickets?status=${encodeURIComponent(status)}` : '/api/extensions/tickets';
const data = await api(url);
const body = document.getElementById('ticketsBody');
body.innerHTML = '';
if (!Array.isArray(data.tickets) || data.tickets.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 8;
cell.textContent = 'No tickets found';
row.appendChild(cell);
body.appendChild(row);
return;
}
data.tickets.forEach(ticket => {
const row = document.createElement('tr');
row.innerHTML = `
<td>#${ticket.id}</td>
<td>${ticket.creatorName}</td>
<td>${ticket.category}</td>
<td>${ticket.description}</td>
<td>${ticket.status}</td>
<td>${formatDate(ticket.createdAt)}</td>
<td>${ticket.handledBy || '-'}</td>
<td></td>
`;
const actionsCell = row.lastElementChild;
if (ticket.status === 'OPEN') {
const closeButton = document.createElement('button');
closeButton.className = 'secondary';
closeButton.style.width = 'auto';
closeButton.textContent = 'Close';
closeButton.addEventListener('click', async () => {
await api(`/api/extensions/tickets/${ticket.id}/close`, { method: 'POST' });
await loadTickets();
});
actionsCell.appendChild(closeButton);
} else {
const reopenButton = document.createElement('button');
reopenButton.style.width = 'auto';
reopenButton.textContent = 'Reopen';
reopenButton.addEventListener('click', async () => {
await api(`/api/extensions/tickets/${ticket.id}/reopen`, { method: 'POST' });
await loadTickets();
});
actionsCell.appendChild(reopenButton);
}
body.appendChild(row);
});
}
document.getElementById('refreshTickets').addEventListener('click', loadTickets);
document.getElementById('ticketStatusFilter').addEventListener('change', loadTickets);
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadTickets();
setInterval(() => loadTickets().catch(() => {}), 5000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
+624
View File
@@ -0,0 +1,624 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Users</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
white-space: nowrap;
}
.permissions-shell {
display: grid;
gap: 10px;
}
.permission-group {
border: 1px solid var(--theme-border, rgba(255, 255, 255, 0.14));
border-radius: 10px;
background: var(--theme-surface-2, rgba(16, 20, 30, 0.82));
overflow: hidden;
}
.permission-group details {
display: block;
}
.permission-group-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border: none;
border-bottom: 1px solid transparent;
background: transparent;
color: inherit;
padding: 10px 12px;
text-align: left;
cursor: pointer;
list-style: none;
}
.permission-group-toggle::-webkit-details-marker {
display: none;
}
.permission-group.open .permission-group-toggle {
border-bottom-color: var(--theme-border, rgba(255, 255, 255, 0.12));
}
.permission-group-title {
font-weight: 600;
font-size: 14px;
}
.permission-group-meta {
font-size: 12px;
opacity: 0.75;
}
.permission-group-chevron {
font-size: 12px;
opacity: 0.75;
transition: transform 0.15s ease;
}
.permission-group.open .permission-group-chevron {
transform: rotate(180deg);
}
.permission-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
padding: 10px 12px 12px;
}
.permission-row {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--theme-border, rgba(255, 255, 255, 0.1));
border-radius: 8px;
padding: 7px 9px;
background: var(--theme-surface-3, rgba(255, 255, 255, 0.02));
}
.permission-row input[type="checkbox"] {
margin: 0;
}
.permission-row.disabled {
opacity: 0.6;
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link active" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Users</h1>
<section class="card spaced-top">
<h2>User Management</h2>
<div class="inline-form">
<button id="openCreateWizard" type="button">New User</button>
</div>
<div id="usersMainContent" class="spaced-top">
<div class="table-wrap">
<table>
<thead><tr><th>ID</th><th>Username</th><th>Role</th><th>Save Role</th><th>Permissions</th><th>Delete</th></tr></thead>
<tbody id="users"></tbody>
</table>
</div>
<div id="permissionEditor" class="spaced-top" style="display:none;">
<h3 id="permissionEditorTitle">Edit permissions</h3>
<div id="permissionEditorGroups"></div>
<div class="spaced-top">
<button id="savePermissions">Save Permissions</button>
</div>
</div>
</div>
<div id="createUserWizard" class="spaced-top" style="display:none;">
<h3 id="createWizardTitle">Create User - Step 1 of 3</h3>
<div id="wizardStep1" class="card spaced-top">
<label for="wizardUsername">Username</label>
<input id="wizardUsername" placeholder="Choose username">
</div>
<div id="wizardStep2" class="card spaced-top" style="display:none;">
<label for="wizardPassword">Password</label>
<input id="wizardPassword" type="password" placeholder="Choose password (min. 10 chars)">
</div>
<div id="wizardStep3" class="card spaced-top" style="display:none;">
<label for="wizardRole">Role Preset</label>
<select id="wizardRole">
<option>VIEWER</option>
<option>ADMIN</option>
</select>
<div id="wizardPermissionGroups" class="spaced-top"></div>
</div>
<div class="inline-form spaced-top">
<button id="wizardCancel" type="button" class="secondary">Cancel</button>
<button id="wizardBack" type="button" class="secondary">Back</button>
<button id="wizardNext" type="button">Next</button>
<button id="wizardCreate" type="button" style="display:none;">Create User</button>
</div>
</div>
<p id="userStatus" class="action-status"></p>
</section>
</main>
</div>
<script>
let permissionCatalog = [];
let roleDefaults = {};
let usersById = new Map();
let selectedUserId = null;
const permissionGroupStateKey = 'minepanel.permissions.groupState';
const createWizardState = {
step: 1,
username: '',
password: '',
role: 'VIEWER',
permissions: []
};
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
function setStatus(message, isError) {
const status = document.getElementById('userStatus');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function groupedPermissions() {
const grouped = new Map();
permissionCatalog.forEach(permission => {
const category = permission.category || 'Other';
if (!grouped.has(category)) grouped.set(category, []);
grouped.get(category).push(permission);
});
return grouped;
}
function loadPermissionGroupState() {
try {
return JSON.parse(sessionStorage.getItem(permissionGroupStateKey) || '{}') || {};
} catch (ignored) {
return {};
}
}
function savePermissionGroupState(state) {
try {
sessionStorage.setItem(permissionGroupStateKey, JSON.stringify(state));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
function renderPermissionCheckboxes(container, selectedPermissions, prefix, disabled) {
const groups = groupedPermissions();
const state = loadPermissionGroupState();
container.innerHTML = '';
container.classList.add('permissions-shell');
let groupIndex = 0;
groups.forEach((items, category) => {
const card = document.createElement('div');
card.className = 'permission-group';
const groupId = `${prefix}:${category}`;
const hasSavedState = Object.prototype.hasOwnProperty.call(state, groupId);
const isOpen = hasSavedState ? state[groupId] === true : groupIndex === 0;
const details = document.createElement('details');
details.open = isOpen;
details.addEventListener('toggle', () => {
state[groupId] = details.open;
savePermissionGroupState(state);
card.classList.toggle('open', details.open);
});
if (details.open) {
card.classList.add('open');
}
const toggle = document.createElement('summary');
toggle.className = 'permission-group-toggle';
toggle.innerHTML = `
<span>
<span class="permission-group-title">${category}</span>
<span class="permission-group-meta">${items.length} permission${items.length === 1 ? '' : 's'}</span>
</span>
<span class="permission-group-chevron">▼</span>
`;
const wrapper = document.createElement('div');
wrapper.className = 'permission-grid';
items.forEach(permission => {
const id = `${prefix}_${permission.key}`;
const checked = selectedPermissions.has(permission.key) ? 'checked' : '';
const lock = disabled ? 'disabled' : '';
const row = document.createElement('label');
row.className = `permission-row${disabled ? ' disabled' : ''}`;
row.innerHTML = `<input type="checkbox" id="${id}" data-permission="${permission.key}" ${checked} ${lock}><span>${permission.label}</span>`;
wrapper.appendChild(row);
});
details.appendChild(toggle);
details.appendChild(wrapper);
card.appendChild(details);
container.appendChild(card);
groupIndex++;
});
}
function selectedPermissionsFromContainer(container) {
const selected = [];
container.querySelectorAll('input[type="checkbox"][data-permission]').forEach(input => {
if (input.checked) selected.push(input.getAttribute('data-permission'));
});
return selected;
}
function defaultPermissionsForRole(role) {
return new Set(Array.isArray(roleDefaults[role]) ? roleDefaults[role] : []);
}
function setCreateWizardVisible(visible) {
document.getElementById('usersMainContent').style.display = visible ? 'none' : '';
document.getElementById('createUserWizard').style.display = visible ? '' : 'none';
}
function resetCreateWizard() {
createWizardState.step = 1;
createWizardState.username = '';
createWizardState.password = '';
createWizardState.role = 'VIEWER';
createWizardState.permissions = [];
document.getElementById('wizardUsername').value = '';
document.getElementById('wizardPassword').value = '';
document.getElementById('wizardRole').value = 'VIEWER';
renderCreateWizardStep();
}
function captureWizardPermissions() {
const groups = document.getElementById('wizardPermissionGroups');
if (groups && groups.children.length > 0) {
createWizardState.permissions = selectedPermissionsFromContainer(groups);
}
}
function renderCreateWizardPermissions(useRoleDefaults) {
if (useRoleDefaults || !Array.isArray(createWizardState.permissions) || createWizardState.permissions.length === 0) {
createWizardState.permissions = Array.from(defaultPermissionsForRole(createWizardState.role));
}
renderPermissionCheckboxes(
document.getElementById('wizardPermissionGroups'),
new Set(createWizardState.permissions),
'create_wizard',
false
);
}
function renderCreateWizardStep() {
const step = createWizardState.step;
document.getElementById('createWizardTitle').textContent = `Create User - Step ${step} of 3`;
document.getElementById('wizardStep1').style.display = step === 1 ? '' : 'none';
document.getElementById('wizardStep2').style.display = step === 2 ? '' : 'none';
document.getElementById('wizardStep3').style.display = step === 3 ? '' : 'none';
document.getElementById('wizardBack').style.display = step === 1 ? 'none' : '';
document.getElementById('wizardNext').style.display = step === 3 ? 'none' : '';
document.getElementById('wizardCreate').style.display = step === 3 ? '' : 'none';
if (step === 1) {
document.getElementById('wizardUsername').value = createWizardState.username;
} else if (step === 2) {
document.getElementById('wizardPassword').value = createWizardState.password;
} else if (step === 3) {
document.getElementById('wizardRole').value = createWizardState.role;
renderCreateWizardPermissions(false);
}
}
async function loadUsers() {
const data = await api('/api/users');
permissionCatalog = Array.isArray(data.permissions) ? data.permissions : [];
roleDefaults = data.roleDefaults || {};
usersById = new Map();
(Array.isArray(data.users) ? data.users : []).forEach(user => usersById.set(String(user.id), user));
const tbody = document.getElementById('users');
tbody.innerHTML = '';
data.users.forEach(user => {
const tr = document.createElement('tr');
const ownerLocked = !!user.isOwner;
tr.innerHTML = `<td>${user.id}</td><td>${user.username}</td><td>
<select data-id="${user.id}" ${ownerLocked ? 'disabled' : ''}>
${['VIEWER','ADMIN'].map(r => `<option ${user.role === r ? 'selected' : ''}>${r}</option>`).join('')}
</select>
</td><td><button data-save="${user.id}">Save</button></td>
<td><span class="muted">-</span></td>
<td><button data-delete="${user.id}" ${ownerLocked ? 'disabled title="Owner users cannot be deleted"' : ''}>Delete</button></td>`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('button[data-save]').forEach(button => {
button.addEventListener('click', async () => {
const id = button.getAttribute('data-save');
const user = usersById.get(id);
if (user && user.isOwner) {
setStatus('Owner role cannot be changed.', true);
return;
}
const role = tbody.querySelector(`select[data-id="${id}"]`).value;
try {
await api(`/api/users/${id}/role`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role })
});
await loadUsers();
setStatus('User role updated successfully.', false);
} catch (error) {
setStatus(`Failed to update user role: ${error.message}`, true);
}
});
});
tbody.querySelectorAll('tr').forEach(row => {
const id = row.querySelector('button[data-save]')?.getAttribute('data-save');
const user = id ? usersById.get(id) : null;
const permissionsCell = row.children[4];
if (!permissionsCell || !user) return;
const button = document.createElement('button');
button.type = 'button';
button.textContent = user.isOwner ? 'Owner (all permissions)' : 'Edit Permissions';
button.disabled = !!user.isOwner;
button.addEventListener('click', () => openPermissionEditor(user));
permissionsCell.innerHTML = '';
permissionsCell.appendChild(button);
});
tbody.querySelectorAll('button[data-delete]').forEach(button => {
button.addEventListener('click', async () => {
const id = button.getAttribute('data-delete');
const row = button.closest('tr');
const username = row ? row.children[1].textContent : `ID ${id}`;
if (!confirm(`Delete panel user '${username}'?`)) {
return;
}
try {
await api(`/api/users/${id}/delete`, {
method: 'POST'
});
await loadUsers();
setStatus(`Deleted user '${username}'.`, false);
} catch (error) {
setStatus(`Failed to delete user '${username}': ${error.message}`, true);
}
});
});
}
function openPermissionEditor(user) {
selectedUserId = String(user.id);
const editor = document.getElementById('permissionEditor');
const title = document.getElementById('permissionEditorTitle');
const groups = document.getElementById('permissionEditorGroups');
title.textContent = `Permissions: ${user.username}`;
const selected = new Set(Array.isArray(user.permissions) ? user.permissions : []);
renderPermissionCheckboxes(groups, selected, `edit_${user.id}`, false);
editor.style.display = 'block';
}
document.getElementById('logout').addEventListener('click', async () => {
try {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
} catch (error) {
setStatus(`Logout failed: ${error.message}`, true);
}
});
document.getElementById('openCreateWizard').addEventListener('click', () => {
resetCreateWizard();
setCreateWizardVisible(true);
});
document.getElementById('wizardCancel').addEventListener('click', () => {
captureWizardPermissions();
setCreateWizardVisible(false);
setStatus('', false);
});
document.getElementById('wizardBack').addEventListener('click', () => {
if (createWizardState.step === 3) {
captureWizardPermissions();
}
createWizardState.step = Math.max(1, createWizardState.step - 1);
renderCreateWizardStep();
});
document.getElementById('wizardNext').addEventListener('click', () => {
if (createWizardState.step === 1) {
const username = document.getElementById('wizardUsername').value.trim();
if (username.length < 3) {
setStatus('Username must be at least 3 characters.', true);
return;
}
createWizardState.username = username;
} else if (createWizardState.step === 2) {
const password = document.getElementById('wizardPassword').value;
if (password.length < 10) {
setStatus('Password must be at least 10 characters.', true);
return;
}
createWizardState.password = password;
createWizardState.permissions = Array.from(defaultPermissionsForRole(createWizardState.role));
}
createWizardState.step = Math.min(3, createWizardState.step + 1);
renderCreateWizardStep();
setStatus('', false);
});
document.getElementById('wizardRole').addEventListener('change', (event) => {
createWizardState.role = event.target.value;
renderCreateWizardPermissions(true);
});
document.getElementById('wizardCreate').addEventListener('click', async () => {
captureWizardPermissions();
try {
await api('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: createWizardState.username,
password: createWizardState.password,
role: createWizardState.role,
permissions: createWizardState.permissions
})
});
await loadUsers();
setCreateWizardVisible(false);
resetCreateWizard();
setStatus('User created successfully.', false);
} catch (error) {
setStatus(`Failed to create user: ${error.message}`, true);
}
});
document.getElementById('savePermissions').addEventListener('click', async () => {
if (!selectedUserId) {
setStatus('No user selected.', true);
return;
}
try {
const permissions = selectedPermissionsFromContainer(document.getElementById('permissionEditorGroups'));
await api(`/api/users/${selectedUserId}/permissions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permissions })
});
await loadUsers();
setStatus('Permissions updated successfully.', false);
} catch (error) {
setStatus(`Failed to update permissions: ${error.message}`, true);
}
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadUsers();
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,305 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Whitelist</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
<a class="side-link" href="/dashboard/extensions">Extensions</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Whitelist</h1>
<p>View, add, and remove whitelisted players.</p>
<section class="card">
<h2>Whitelist Status</h2>
<div class="toolbar">
<strong id="whitelistStatus" style="align-self:center;min-width:180px;">Status: Unknown</strong>
<button id="enableWhitelistBtn">Enable Whitelist</button>
<button id="disableWhitelistBtn" class="secondary">Disable Whitelist</button>
</div>
</section>
<section class="card spaced-top">
<h2>Add Player</h2>
<div class="toolbar">
<input id="usernameInput" placeholder="Minecraft username">
<button id="addBtn">Add to Whitelist</button>
<button id="refreshBtn" class="secondary">Refresh</button>
</div>
<div id="statusMessage" class="warning" style="display:none;margin-top:10px;"></div>
</section>
<section class="card spaced-top">
<h2>Whitelisted Players (<span id="count">0</span>)</h2>
<table>
<thead>
<tr>
<th>Username</th>
<th>UUID</th>
<th>Online</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="whitelistBody"></tbody>
</table>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const nextExpanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', nextExpanded);
toggle.classList.toggle('expanded', nextExpanded);
savedState[category] = nextExpanded;
persistState();
});
});
}
async function api(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
const error = new Error(payload.error || 'Request failed');
error.status = response.status;
throw error;
}
return payload;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function showStatus(message, isError) {
const box = document.getElementById('statusMessage');
if (!message) {
box.style.display = 'none';
box.textContent = '';
return;
}
box.style.display = 'block';
box.textContent = message;
box.style.borderColor = isError ? 'rgba(231, 76, 60, 0.45)' : 'rgba(46, 204, 113, 0.45)';
box.style.background = isError ? 'rgba(231, 76, 60, 0.16)' : 'rgba(46, 204, 113, 0.14)';
box.style.color = isError ? '#ffc7c1' : '#b9f8cf';
}
function renderWhitelist(entries) {
const tbody = document.getElementById('whitelistBody');
tbody.innerHTML = '';
if (!Array.isArray(entries) || entries.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4">No whitelisted players found</td>';
tbody.appendChild(row);
return;
}
for (const entry of entries) {
const row = document.createElement('tr');
row.innerHTML = `
<td>${entry.username || '-'}</td>
<td>${entry.uuid || '-'}</td>
<td>${entry.online ? 'Yes' : 'No'}</td>
<td><button class="danger" data-remove="${entry.username || ''}">Remove</button></td>
`;
tbody.appendChild(row);
}
tbody.querySelectorAll('button[data-remove]').forEach(button => {
button.addEventListener('click', async () => {
const username = button.dataset.remove || '';
if (!username) {
return;
}
await removeFromWhitelist(username);
});
});
}
function applyWhitelistStatus(enabled) {
const label = document.getElementById('whitelistStatus');
const active = enabled === true;
label.textContent = `Status: ${active ? 'Enabled' : 'Disabled'}`;
label.style.color = active ? '#8af0b4' : '#ff9ca1';
}
async function loadWhitelistStatus() {
const data = await api('/api/extensions/whitelist/status');
applyWhitelistStatus(data.enabled === true);
}
async function setWhitelistEnabled(enabled) {
const data = await api('/api/extensions/whitelist/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enabled === true })
});
applyWhitelistStatus(data.enabled === true);
showStatus(`Whitelist ${data.enabled ? 'enabled' : 'disabled'}.`, false);
}
async function loadWhitelist() {
const data = await api('/api/extensions/whitelist');
const entries = Array.isArray(data.entries) ? data.entries : [];
document.getElementById('count').textContent = String(data.count || entries.length);
renderWhitelist(entries);
}
async function addToWhitelist() {
const input = document.getElementById('usernameInput');
const username = input.value.trim();
if (!username) {
showStatus('Please enter a username.', true);
return;
}
await api('/api/extensions/whitelist/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
input.value = '';
showStatus(`Added ${username} to whitelist.`, false);
await loadWhitelist();
}
async function removeFromWhitelist(username) {
await api('/api/extensions/whitelist/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
showStatus(`Removed ${username} from whitelist.`, false);
await loadWhitelist();
}
document.getElementById('addBtn').addEventListener('click', async () => {
try {
await addToWhitelist();
} catch (error) {
showStatus(error.message || 'Could not add player.', true);
}
});
document.getElementById('refreshBtn').addEventListener('click', async () => {
try {
await loadWhitelistStatus();
await loadWhitelist();
showStatus('', false);
} catch (error) {
showStatus(error.message || 'Could not refresh whitelist.', true);
}
});
document.getElementById('enableWhitelistBtn').addEventListener('click', async () => {
try {
await setWhitelistEnabled(true);
} catch (error) {
showStatus(error.message || 'Could not enable whitelist.', true);
}
});
document.getElementById('disableWhitelistBtn').addEventListener('click', async () => {
try {
await setWhitelistEnabled(false);
} catch (error) {
showStatus(error.message || 'Could not disable whitelist.', true);
}
});
document.getElementById('usernameInput').addEventListener('keydown', async event => {
if (event.key === 'Enter') {
event.preventDefault();
try {
await addToWhitelist();
} catch (error) {
showStatus(error.message || 'Could not add player.', true);
}
}
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadWhitelistStatus();
await loadWhitelist();
} catch (error) {
if (error && error.status === 401) {
window.location.href = '/';
return;
}
showStatus((error && error.message) ? error.message : 'Could not load whitelist page.', true);
console.error('Whitelist page load failed:', error);
}
})();
</script>
</body>
</html>
@@ -0,0 +1,289 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - World Backups</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link active" href="/dashboard/world-backups">Backups</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
<a class="side-link" href="/dashboard/reports">Reports</a>
<a class="side-link" href="/dashboard/tickets">Tickets</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>World Backups</h1>
<p>Create separate backups for Overworld, Nether and End. Backups are saved to <code>plugins/MinePanel/backups</code>.</p>
<section class="card">
<h2>Create Backup</h2>
<div class="toolbar">
<select id="backupWorld" style="width:auto;min-width:180px;">
<option value="overworld">Overworld</option>
<option value="nether">Nether</option>
<option value="end">End</option>
</select>
<input id="backupName" placeholder="Backup name (example: before-update)">
<button id="createBackup">Create Backup</button>
</div>
<p id="backupStatus" class="status-line"></p>
</section>
<section class="card spaced-top">
<h2>Overworld Backups</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>File</th>
<th>Created</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="overworldBackups"></tbody>
</table>
</section>
<section class="card spaced-top">
<h2>Nether Backups</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>File</th>
<th>Created</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="netherBackups"></tbody>
</table>
</section>
<section class="card spaced-top">
<h2>End Backups</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>File</th>
<th>Created</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="endBackups"></tbody>
</table>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
const detail = payload && payload.details ? ` (${payload.details})` : '';
throw new Error(`${payload.error || 'request_failed'}${detail}`);
}
return payload;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function formatDate(value) {
if (!value || Number(value) <= 0) {
return '-';
}
return new Date(Number(value)).toLocaleString();
}
function formatSize(bytes) {
const value = Number(bytes) || 0;
if (value < 1024) return `${value} B`;
const kb = value / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
const gb = mb / 1024;
return `${gb.toFixed(2)} GB`;
}
function renderBackupList(world, backups) {
const body = document.getElementById(`${world}Backups`);
body.innerHTML = '';
if (!Array.isArray(backups) || backups.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 5;
cell.textContent = 'No backups yet';
row.appendChild(cell);
body.appendChild(row);
return;
}
for (const backup of backups) {
const row = document.createElement('tr');
row.innerHTML = `
<td>${backup.name || '-'}</td>
<td>${backup.fileName || '-'}</td>
<td>${formatDate(backup.createdAt)}</td>
<td>${formatSize(backup.sizeBytes)}</td>
<td></td>
`;
const actionsCell = row.lastElementChild;
const deleteButton = document.createElement('button');
deleteButton.className = 'secondary';
deleteButton.style.width = 'auto';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', async () => {
await deleteBackup(world, backup.fileName);
});
actionsCell.appendChild(deleteButton);
body.appendChild(row);
}
}
async function deleteBackup(world, fileName) {
const status = document.getElementById('backupStatus');
if (!fileName) {
status.textContent = 'Delete failed: invalid file name.';
return;
}
const confirmed = window.confirm(`Delete backup ${fileName}?`);
if (!confirmed) {
return;
}
status.textContent = `Deleting backup ${fileName}...`;
try {
await api('/api/extensions/world-backups/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ world, fileName })
});
status.textContent = `Deleted backup ${fileName}.`;
await loadBackups();
} catch (error) {
status.textContent = `Delete failed: ${error.message}`;
}
}
async function loadBackups() {
const data = await api('/api/extensions/world-backups');
renderBackupList('overworld', data.overworld || []);
renderBackupList('nether', data.nether || []);
renderBackupList('end', data.end || []);
}
async function createBackup() {
const world = document.getElementById('backupWorld').value;
const name = document.getElementById('backupName').value.trim();
const status = document.getElementById('backupStatus');
if (!name) {
status.textContent = 'Please enter a backup name.';
return;
}
status.textContent = 'Creating backup...';
try {
await api('/api/extensions/world-backups/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ world, name })
});
status.textContent = 'Backup created successfully.';
document.getElementById('backupName').value = '';
await loadBackups();
} catch (error) {
status.textContent = `Backup failed: ${error.message}`;
}
}
document.getElementById('createBackup').addEventListener('click', createBackup);
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadBackups();
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More