first commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle
|
||||||
|
.idea
|
||||||
|
build
|
||||||
|
gradle
|
||||||
|
run
|
||||||
|
folia
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# MinePanel
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
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
@@ -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)
|
||||||
@@ -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
@@ -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 |
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
+160
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+188
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+173
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
+141
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+29
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+25
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+308
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+164
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+112
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+182
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+145
@@ -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
@@ -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
|
||||||
|
|
||||||
@@ -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><gold>[MinePanel]</gold></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>
|
||||||
|
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -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
Reference in New Issue
Block a user