From 61ae38701e55dd6972fca32a4de9a006375261ab Mon Sep 17 00:00:00 2001 From: Patrick <147879351+WinniePatGG@users.noreply.github.com> Date: Fri, 1 May 2026 18:46:17 +0200 Subject: [PATCH] first commit --- .gitignore | 6 + LICENSE | 21 + README.md | 149 + build.gradle | 316 ++ gradle.properties | 0 gradlew | 249 ++ gradlew.bat | 92 + img/setup_token.png | Bin 0 -> 24692 bytes settings.gradle | 1 + .../de/winniepat/minePanel/MinePanel.java | 212 + .../minePanel/auth/PasswordHasher.java | 15 + .../minePanel/auth/SessionService.java | 108 + .../minePanel/config/WebPanelConfig.java | 73 + .../BukkitExtensionCommandRegistry.java | 200 + .../extensions/ExtensionCommandRegistry.java | 37 + .../extensions/ExtensionConfigurable.java | 15 + .../extensions/ExtensionContext.java | 20 + .../extensions/ExtensionManager.java | 566 +++ .../extensions/ExtensionNavigationTab.java | 9 + .../extensions/ExtensionRouteHandler.java | 11 + .../extensions/ExtensionWebRegistry.java | 16 + .../extensions/MinePanelExtension.java | 88 + .../airstrike/AirstrikeExtension.java | 233 ++ .../announcements/Announcement.java | 12 + .../announcements/AnnouncementRepository.java | 160 + .../announcements/AnnouncementService.java | 188 + .../announcements/AnnouncementsExtension.java | 173 + .../luckperms/LuckPermsExtension.java | 108 + .../maintenance/MaintenanceExtension.java | 141 + .../maintenance/MaintenanceJoinListener.java | 29 + .../maintenance/MaintenanceMotdListener.java | 25 + .../maintenance/MaintenanceService.java | 157 + .../PlayerManagementExtension.java | 308 ++ .../playermanagement/PlayerMute.java | 21 + .../playermanagement/PlayerMuteListener.java | 164 + .../PlayerMuteRepository.java | 112 + .../playerstats/PlayerStatsExtension.java | 182 + .../extensions/reports/PlayerReport.java | 18 + .../extensions/reports/ReportCommand.java | 73 + .../extensions/reports/ReportRepository.java | 130 + .../reports/ReportSystemExtension.java | 182 + .../extensions/tickets/PlayerTicket.java | 18 + .../extensions/tickets/TicketCommand.java | 28 + .../tickets/TicketMenuListener.java | 107 + .../extensions/tickets/TicketRepository.java | 120 + .../tickets/TicketSystemExtension.java | 131 + .../whitelist/WhitelistExtension.java | 139 + .../whitelist/WhitelistService.java | 155 + .../worldbackups/WorldBackupService.java | 389 ++ .../worldbackups/WorldBackupsExtension.java | 145 + .../integrations/DiscordWebhookConfig.java | 37 + .../DiscordWebhookRepository.java | 94 + .../integrations/DiscordWebhookService.java | 136 + .../lifecycle/PluginLifecycleSupport.java | 136 + .../minePanel/logs/ChatCaptureListener.java | 23 + .../logs/CommandCaptureListener.java | 25 + .../minePanel/logs/PanelLogEntry.java | 11 + .../winniepat/minePanel/logs/PanelLogger.java | 26 + .../logs/PlayerActivityListener.java | 144 + .../minePanel/logs/ServerLogService.java | 88 + .../minePanel/persistence/Database.java | 143 + .../ExtensionSettingsRepository.java | 64 + .../persistence/JoinLeaveEventRepository.java | 66 + .../minePanel/persistence/KnownPlayer.java | 11 + .../persistence/KnownPlayerRepository.java | 100 + .../minePanel/persistence/LogRepository.java | 97 + .../persistence/OAuthAccountRepository.java | 153 + .../persistence/OAuthStateRepository.java | 122 + .../minePanel/persistence/PlayerActivity.java | 16 + .../persistence/PlayerActivityRepository.java | 152 + .../minePanel/persistence/UserRepository.java | 317 ++ .../minePanel/users/PanelPermission.java | 77 + .../winniepat/minePanel/users/PanelUser.java | 41 + .../minePanel/users/PanelUserAuth.java | 8 + .../winniepat/minePanel/users/UserRole.java | 82 + .../minePanel/util/ServerSchedulerBridge.java | 184 + .../minePanel/web/BootstrapService.java | 41 + .../minePanel/web/ResourceLoader.java | 22 + .../minePanel/web/WebAssetService.java | 166 + .../minePanel/web/WebPanelServer.java | 3402 +++++++++++++++++ src/main/resources/config.yml | 25 + src/main/resources/plugin.yml | 20 + src/main/resources/web/dashboard-account.html | 240 ++ .../web/dashboard-announcements.html | 444 +++ src/main/resources/web/dashboard-bans.html | 191 + src/main/resources/web/dashboard-console.html | 181 + .../web/dashboard-discord-webhook.html | 216 ++ .../web/dashboard-extension-config.html | 543 +++ .../resources/web/dashboard-extensions.html | 417 ++ .../resources/web/dashboard-maintenance.html | 349 ++ .../resources/web/dashboard-overview.html | 523 +++ src/main/resources/web/dashboard-players.html | 710 ++++ src/main/resources/web/dashboard-plugins.html | 368 ++ src/main/resources/web/dashboard-reports.html | 205 + .../resources/web/dashboard-resources.html | 172 + src/main/resources/web/dashboard-themes.html | 583 +++ src/main/resources/web/dashboard-tickets.html | 200 + src/main/resources/web/dashboard-users.html | 624 +++ .../resources/web/dashboard-whitelist.html | 305 ++ .../web/dashboard-world-backups.html | 289 ++ src/main/resources/web/login.html | 115 + src/main/resources/web/panel.css | 968 +++++ src/main/resources/web/setup.html | 48 + src/main/resources/web/theme.js | 486 +++ 104 files changed, 20058 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 img/setup_token.png create mode 100644 settings.gradle create mode 100644 src/main/java/de/winniepat/minePanel/MinePanel.java create mode 100644 src/main/java/de/winniepat/minePanel/auth/PasswordHasher.java create mode 100644 src/main/java/de/winniepat/minePanel/auth/SessionService.java create mode 100644 src/main/java/de/winniepat/minePanel/config/WebPanelConfig.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/BukkitExtensionCommandRegistry.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/ExtensionCommandRegistry.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/ExtensionConfigurable.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/ExtensionContext.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/ExtensionManager.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/ExtensionNavigationTab.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/ExtensionRouteHandler.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/ExtensionWebRegistry.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/MinePanelExtension.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/airstrike/AirstrikeExtension.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/announcements/Announcement.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/announcements/AnnouncementRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/announcements/AnnouncementService.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/announcements/AnnouncementsExtension.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/luckperms/LuckPermsExtension.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceExtension.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceJoinListener.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceMotdListener.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/maintenance/MaintenanceService.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerManagementExtension.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerMute.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerMuteListener.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/playermanagement/PlayerMuteRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/playerstats/PlayerStatsExtension.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/reports/PlayerReport.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/reports/ReportCommand.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/reports/ReportRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/reports/ReportSystemExtension.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/tickets/PlayerTicket.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/tickets/TicketCommand.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/tickets/TicketMenuListener.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/tickets/TicketRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/tickets/TicketSystemExtension.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/whitelist/WhitelistExtension.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/whitelist/WhitelistService.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/worldbackups/WorldBackupService.java create mode 100644 src/main/java/de/winniepat/minePanel/extensions/worldbackups/WorldBackupsExtension.java create mode 100644 src/main/java/de/winniepat/minePanel/integrations/DiscordWebhookConfig.java create mode 100644 src/main/java/de/winniepat/minePanel/integrations/DiscordWebhookRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/integrations/DiscordWebhookService.java create mode 100644 src/main/java/de/winniepat/minePanel/lifecycle/PluginLifecycleSupport.java create mode 100644 src/main/java/de/winniepat/minePanel/logs/ChatCaptureListener.java create mode 100644 src/main/java/de/winniepat/minePanel/logs/CommandCaptureListener.java create mode 100644 src/main/java/de/winniepat/minePanel/logs/PanelLogEntry.java create mode 100644 src/main/java/de/winniepat/minePanel/logs/PanelLogger.java create mode 100644 src/main/java/de/winniepat/minePanel/logs/PlayerActivityListener.java create mode 100644 src/main/java/de/winniepat/minePanel/logs/ServerLogService.java create mode 100644 src/main/java/de/winniepat/minePanel/persistence/Database.java create mode 100644 src/main/java/de/winniepat/minePanel/persistence/ExtensionSettingsRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/persistence/JoinLeaveEventRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/persistence/KnownPlayer.java create mode 100644 src/main/java/de/winniepat/minePanel/persistence/KnownPlayerRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/persistence/LogRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/persistence/OAuthAccountRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/persistence/OAuthStateRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/persistence/PlayerActivity.java create mode 100644 src/main/java/de/winniepat/minePanel/persistence/PlayerActivityRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/persistence/UserRepository.java create mode 100644 src/main/java/de/winniepat/minePanel/users/PanelPermission.java create mode 100644 src/main/java/de/winniepat/minePanel/users/PanelUser.java create mode 100644 src/main/java/de/winniepat/minePanel/users/PanelUserAuth.java create mode 100644 src/main/java/de/winniepat/minePanel/users/UserRole.java create mode 100644 src/main/java/de/winniepat/minePanel/util/ServerSchedulerBridge.java create mode 100644 src/main/java/de/winniepat/minePanel/web/BootstrapService.java create mode 100644 src/main/java/de/winniepat/minePanel/web/ResourceLoader.java create mode 100644 src/main/java/de/winniepat/minePanel/web/WebAssetService.java create mode 100644 src/main/java/de/winniepat/minePanel/web/WebPanelServer.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/plugin.yml create mode 100644 src/main/resources/web/dashboard-account.html create mode 100644 src/main/resources/web/dashboard-announcements.html create mode 100644 src/main/resources/web/dashboard-bans.html create mode 100644 src/main/resources/web/dashboard-console.html create mode 100644 src/main/resources/web/dashboard-discord-webhook.html create mode 100644 src/main/resources/web/dashboard-extension-config.html create mode 100644 src/main/resources/web/dashboard-extensions.html create mode 100644 src/main/resources/web/dashboard-maintenance.html create mode 100644 src/main/resources/web/dashboard-overview.html create mode 100644 src/main/resources/web/dashboard-players.html create mode 100644 src/main/resources/web/dashboard-plugins.html create mode 100644 src/main/resources/web/dashboard-reports.html create mode 100644 src/main/resources/web/dashboard-resources.html create mode 100644 src/main/resources/web/dashboard-themes.html create mode 100644 src/main/resources/web/dashboard-tickets.html create mode 100644 src/main/resources/web/dashboard-users.html create mode 100644 src/main/resources/web/dashboard-whitelist.html create mode 100644 src/main/resources/web/dashboard-world-backups.html create mode 100644 src/main/resources/web/login.html create mode 100644 src/main/resources/web/panel.css create mode 100644 src/main/resources/web/setup.html create mode 100644 src/main/resources/web/theme.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..687e481 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.gradle +.idea +build +gradle +run +folia \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..51387e5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Patrick + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd3f1ce --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# MinePanel + +![MinePanel](https://img.shields.io/badge/MinePanel-Web%20Panel%20for%20Paper-4f8cff?style=for-the-badge) +![Java](https://img.shields.io/badge/Java-21-ff8a65?style=for-the-badge) +![Paper](https://img.shields.io/badge/Paper-1.21.x-58d68d?style=for-the-badge) +![Folia](https://img.shields.io/badge/Folia-Compatible-8e44ad?style=for-the-badge) +![Extensions](https://img.shields.io/badge/Extensions-Modular-9b59b6?style=for-the-badge) + +MinePanel is a Paper plugin that runs an embedded web panel for Minecraft server administration. + +The plugin is compatible with both Paper and Folia (1.21.x). + +The core plugin provides a secure web UI (login, roles, sessions, dashboard pages, logs, users, etc.) and an extension system so extra features can be added as separate jars. + +## What You Get + +### Core (works without extensions) + +- 🖥️ Embedded HTTP server for panel pages and API. +- 🔐 First-launch bootstrap flow to create the owner account. +- 🛡️ Secure auth with BCrypt + server-side sessions. +- 👥 Role/permission based panel access (`OWNER`, `ADMIN`, `VIEWER`). +- 📜 Console page with live panel log updates and command sending. +- 📊 Overview page with: + - TPS, memory, CPU cards + - TPS/Memory/CPU time-series charts + - Join/leave heatmaps (day x hour) +- 🎮 Players page with profile details and activity info. +- 🔌 Plugin and bans pages. +- 🎨 Themes page. +- 🧩 Extension management page. + +### Extension system + +- Core scans `plugins/MinePanel/extensions` on startup. +- If no extension jars are installed, you get the default panel only. +- If extension jars are present, their routes/tabs/features are loaded after restart. +- Extensions are discovered via Java `ServiceLoader` using: + - `META-INF/services/de.winniepat.minePanel.extensions.MinePanelExtension` +### All Extensions and their features are listed [here](https://github.com/WinniePatGG/MinePanel/tree/main/docs/AVAILABLE-EXTENSIONS.md) +## Runtime Configuration + +Default config in `src/main/resources/config.yml`: + +```yaml +web: + host: 127.0.0.1 + port: 8080 + sessionTtlMinutes: 120 + +security: + bootstrapTokenLength: 32 + +integrations: + github: + token: "" + releaseCacheSeconds: 300 +``` + +Use `integrations.github.token` (or environment variable `MINEPANEL_GITHUB_TOKEN`) for authenticated GitHub API requests and higher rate limits. + +## Build + +### Build core + extension jars + +```powershell +.\gradlew.bat assemble +``` + +### Build only core shadow jar + +```powershell +.\gradlew.bat shadowJar +``` + +## Build Outputs + +- Core plugin jar: + - `build/libs/MinePanel-.jar` +- Reports extension jar: + - `build/libs/extensions/MinePanel-Extension-Reports-.jar` +- Player-management extension jar: + - `build/libs/extensions/MinePanel-Extension-PlayerManagement-.jar` +- LuckPerms extension jar: + - `build/libs/extensions/MinePanel-Extension-LuckPerms-.jar` +- Player Stats extension jar: + - `build/libs/extensions/MinePanel-Extension-PlayerStats-.jar` + +## Installation + +1. Copy core jar to your server `plugins` folder. +2. Start server once. +3. Check console for the bootstrap token. +4. Open panel in browser: `http://127.0.0.1:8080` (or your configured host/port). +5. Complete setup and create owner account. + +## Installing Extensions + +1. Build or download extension jars. +2. Put them in: + - `plugins/MinePanel/extensions` +3. Restart the server. +4. New extension tabs/features become available. + +## Extension Links + +- GitHub releases: `https://github.com/WinniePatGG/MinePanel/releases` +- The panel reads extension assets from the latest selected channel (Release or Pre-release). +- If the GitHub API rate limit is reached, MinePanel falls back to cached release data and shows a warning in the Extensions tab. + +## Included Extension Artifacts in This Repo + +This repository can build two extension jars: + +- `MinePanel-Extension-Reports-*` + - Adds report system features. +- `MinePanel-Extension-PlayerManagement-*` + - Adds moderation/mute related player-management features. +- `MinePanel-Extension-LuckPerms-*` + - Adds LuckPerms player details in Players tab (groups, permissions, prefix/suffix). +- `MinePanel-Extension-PlayerStats-*` + - Adds player kills, deaths and economy balance (if Vault-compatible economy is present). + +## Writing Third-Party Extensions + +Detailed step-by-step guide: + +- `docs/EXTENSIONS.md` + +Implement `de.winniepat.minePanel.extensions.MinePanelExtension`. + +Typical flow: + +1. Implement extension class (`id`, `displayName`, lifecycle hooks). +2. Add service descriptor file: + - `META-INF/services/de.winniepat.minePanel.extensions.MinePanelExtension` +3. (Optional) Register panel routes via `registerWebRoutes(...)`. +4. (Optional) Add sidebar tabs via `navigationTabs()`. +5. (Optional) Register runtime commands via `ExtensionContext.commandRegistry()`. +6. Package jar and drop into `plugins/MinePanel/extensions`. + +## Security Notes + +- Use a reverse proxy + HTTPS in production. +- Restrict direct access to panel port (`web.port`) with firewall/network rules. +- Treat bootstrap tokens and owner credentials as sensitive secrets. + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..a1a8bdb --- /dev/null +++ b/build.gradle @@ -0,0 +1,316 @@ +plugins { + id 'java' + id("xyz.jpenilla.run-paper") version "2.3.1" + id 'com.gradleup.shadow' version '8.3.5' +} + +group = 'de.winniepat' +version = 'alpha-11' + +base { + archivesName = 'MinePanel' +} + +repositories { + mavenCentral() + maven { + name = "papermc-repo" + url = "https://repo.papermc.io/repository/maven-public/" + } + maven { + name = "luckperms-repo" + url = "https://repo.lucko.me/" + } +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") + compileOnly("net.luckperms:api:5.4") + implementation("com.sparkjava:spark-core:2.9.4") + implementation("org.xerial:sqlite-jdbc:3.46.1.3") + implementation("org.mindrot:jbcrypt:0.4") + implementation("com.google.code.gson:gson:2.11.0") +} + +tasks.named('shadowJar') { + archiveClassifier.set('') + exclude('de/winniepat/minePanel/extensions/reports/**') + exclude('de/winniepat/minePanel/extensions/playermanagement/**') + exclude('de/winniepat/minePanel/extensions/luckperms/**') + exclude('de/winniepat/minePanel/extensions/playerstats/**') + exclude('de/winniepat/minePanel/extensions/tickets/**') + exclude('de/winniepat/minePanel/extensions/worldbackups/**') + exclude('de/winniepat/minePanel/extensions/airstrike/**') + exclude('de/winniepat/minePanel/extensions/maintenance/**') + exclude('de/winniepat/minePanel/extensions/whitelist/**') + exclude('de/winniepat/minePanel/extensions/announcements/**') +} + +def extensionOutputDir = layout.buildDirectory.dir('libs/extensions') + +tasks.register('reportsExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel reports extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-Reports') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/reports/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.reports.ReportSystemExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('playerManagementExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel player-management extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-PlayerManagement') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/playermanagement/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.playermanagement.PlayerManagementExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('luckPermsExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel LuckPerms extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-LuckPerms') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/luckperms/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.luckperms.LuckPermsExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('playerStatsExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel player stats extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-PlayerStats') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/playerstats/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.playerstats.PlayerStatsExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('ticketsExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel ticket-system extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-Tickets') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/tickets/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.tickets.TicketSystemExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('worldBackupsExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel world-backups extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-WorldBackups') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/worldbackups/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.worldbackups.WorldBackupsExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('airstrikeExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel airstrike extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-Airstrike') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/airstrike/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.airstrike.AirstrikeExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('maintenanceExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel maintenance extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-Maintenance') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/maintenance/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.maintenance.MaintenanceExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('whitelistExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel whitelist extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-Whitelist') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/whitelist/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.whitelist.WhitelistExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('announcementsExtensionJar', Jar) { + group = 'build' + description = 'Builds the MinePanel announcements extension jar.' + dependsOn(tasks.named('classes')) + + archiveBaseName.set('MinePanel-Extension-Announcements') + archiveVersion.set(project.version.toString()) + destinationDirectory.set(extensionOutputDir) + + from(sourceSets.main.output) { + include('de/winniepat/minePanel/extensions/announcements/**') + } + + from(resources.text.fromString('de.winniepat.minePanel.extensions.announcements.AnnouncementsExtension\n')) { + into('META-INF/services') + rename { 'de.winniepat.minePanel.extensions.MinePanelExtension' } + } +} + +tasks.register('installExtensionsToRunServer', Copy) { + group = 'build' + description = 'Copies built extension jars into run/plugins/MinePanel/extensions for local testing.' + dependsOn( + tasks.named('reportsExtensionJar'), + tasks.named('playerManagementExtensionJar'), + tasks.named('luckPermsExtensionJar'), + tasks.named('playerStatsExtensionJar'), + tasks.named('ticketsExtensionJar'), + tasks.named('worldBackupsExtensionJar'), + tasks.named('airstrikeExtensionJar'), + tasks.named('maintenanceExtensionJar'), + tasks.named('whitelistExtensionJar'), + tasks.named('announcementsExtensionJar') + ) + + from(extensionOutputDir) + include('*.jar') + into(layout.projectDirectory.dir('run/plugins/MinePanel/extensions')) +} + +tasks.named('assemble') { + dependsOn(tasks.named('shadowJar')) + dependsOn(tasks.named('reportsExtensionJar')) + dependsOn(tasks.named('playerManagementExtensionJar')) + dependsOn(tasks.named('luckPermsExtensionJar')) + dependsOn(tasks.named('playerStatsExtensionJar')) + dependsOn(tasks.named('ticketsExtensionJar')) + dependsOn(tasks.named('worldBackupsExtensionJar')) + dependsOn(tasks.named('airstrikeExtensionJar')) + dependsOn(tasks.named('maintenanceExtensionJar')) + dependsOn(tasks.named('whitelistExtensionJar')) + dependsOn(tasks.named('announcementsExtensionJar')) +} + +tasks { + runServer { + minecraftVersion("1.21.11") + } +} + +def targetJavaVersion = 21 +java { + def javaVersion = JavaVersion.toVersion(targetJavaVersion) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + if (JavaVersion.current() < javaVersion) { + toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) + } +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + + if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) { + options.release.set(targetJavaVersion) + } +} + +processResources { + def props = [version: version] + inputs.properties props + filteringCharset 'UTF-8' + filesMatching('plugin.yml') { + expand props + } +} + +tasks.register('copyPlugin', Copy) { + dependsOn build + from("$buildDir/libs") + include('*.jar') + into("F:/MinePanel/folia/plugins") +} + +build.finalizedBy(copyPlugin) \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..e69de29 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..b740cf1 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/img/setup_token.png b/img/setup_token.png new file mode 100644 index 0000000000000000000000000000000000000000..cfc1e45f9bad505b1ff0dc6ccded2edc66d944b7 GIT binary patch literal 24692 zcmcG#1yoyY`{hkbTM9*59EufpcPJ^exO;IY#oYrGE5+R790lb^%!_-Irw`B39c6S}P*AWt|NK1abtp1NK`};` z{UEO4X$W2R_=w+&{CRp!&KUTdnc6hyo9X+PFGy*AbP;25NhQotyGQ-Fe%bQlgQ95{ z?q%tRC|vxmFKmSPs-_NKpW?qE{O|$))rZro>t1q4k0qzj&s>n91Fs`5SD?GQyQTeg zO-)PaP_w=Ty7u3G9lk z!SJ4}SlUu4SMZN)H<6f^wRB&FXckO3)b$!bF0DWZ^Uey#zy^o4&~dOzTX38O%h1Uk zmjm3=jeBrdz2MDq0ZRiVF@E25r>U{qged9T?pdI8*%kdU#k^% z-UQgD%_t=q#vKiw&v2N-WimCOtUCPP4r(vUmc4?1ej6SlJ)_{a_NAgpf(e*5da%EB`a0UKqH?!5_N!-Uk?{CGcsSka12#6LYeIzS8Ft45K>?XoGolKaVR}_7{ zzjU?OZiB-@7+oMqhX*a|UT^Equ{QH#A|E=7&gi&^X+M2k-AYLiPW5}x9&mXaBy4u2 zTXYh)PdnXa@-rhWu_19YhV0W$P91Hx!F6O)9ZUoa*&{ zijJArJFj(e;&exbxA>pa^?-jNnAnjQS5b6HpX841ugGqkgj6{q+L2GiZ;;adUCGjP z)`^gj@K>lhUi2g(?%c&oZG@7Cg7?t?gJG0KkuOHR2KVepN_SloUsU?+w)Yg9TrpkgXk+O86&5zB2W=vc+$s(t zz8?Yw%J)%#7(5D^7b4ruk0Mh>N$($y>ejbkg&t0%()6s+AS7;jklv_6CeK8+*(6?25t`-=iyq74YyjdCjM6gI>_S!zY*!Y!>1&a-++g&k|j7nxv?;rw)gK zN3Wu`#(baib?#IhO}Id5AFhd67%v%ntE{fB$(qU!IG=OhN+MDPlfNC5l)Ap=Sm2kV z106fz@AC*0Sf*xVm}o5o#y817l9$sHrQ@T!{&_9PN01gmm;Y#pjZu<5&02jvjbyJi zKEpJrm4Qi7XkTOB#Q*7e(($V|Guy}gf#T3I1c7TYIceyKA{ZnuWY&| zl3gxqj5J=G58a(x%kxctS|6I$+(<*5gBe${e#VZ}qsU<3=|3CF_1Kc}1B)dxy=XY= zaG^JAUa%sHJMg0C;OefWmBCP7p%e?nrt8$C{^#F&Ms%=%y$X+>FXM2_E3l)Ovm**B?p)pg_JD-i9- zpF@bH$9O=)^s`L|&wMA^WjpEgQXub(bX(?W(!N-7rHJ-~g7zX(yOPWAoF>Ui8O*Nv z!?rGUxaIwg*LtDGEXLGR@t3tEH7k+|#ZsW*q{qvrKYK#YT5L2cpf_pDK$@mTn^&;h7v4+^J*x-t{gqBp3KL;*W}baqrlo^cx5%q#l){w$n-|#PFLaz=HX0HSEI%Q^$h-gH8 z<1y#qNy$?E@!KVd&L5Mw3a2l=X1*J52*snfv@C)kHpI9S$zd@yE<5yJrlWwJ9n(TY zPqr^x({5qnftm-(@G8~9rt9*<=I!Uuu;pEMq;e7huiAoE@2CO*35pWo# zDnsEz6&KtZPN$OGaLjhYBC*emebBsP*M{+V9#N{DoM^SEkHM*A9-qSx5do#vK;RSl< zOOE4aUeHw>=(V+DKOhffb>Jd#HhfuU_v#mr!iIq`K!Y0O!a?1GeQtU29Zm|Na&p@+ z?s+KxWR9;~kA>3NR0pIrX>VIVZ_7DmfJ9urjm~hR7}C?^;%A+U9QHcOh#6u^*}a~q z(eLxfbz13yIu^A6hP$pC7R$b9xrM=fd$sq?B_&8j zyMwGol*1(Nl(h|0Ys+~w*`mCR>RhNvc`HyMPAYyr6tto%zN_nRe}G}`mb{vZkcaOs z%A^^oeYSi0dO|J1B|9g%s&IKq5@bT!b!(=xsw&h|uUH*eZ``6_FpGZ7;zj zzlE8b7i(f5D$8DrXZcCWU=D>s>&i@$rZ~|30t_21r zqqgT~`S|X8V{c|C@ijUf1^46<#CN2-qc*GWt$W|Qi4x{O?$0yDXgX_aewKGW zXE&RYRsT3)9?8+UZ)nzul<2Rm3<%1_!`2)yv)<&fC9KC56c$pcr%lz>?D#c~ z-oov*x{%O+?7aDrdc>{NhGA<1JB1prc-2ajlDAAcD>>-4+JdPf6H=DNWpuY#=qZcA zGoa=2@YXQvt)ZV?7$SwFf`VFwWvURMcf=$R&*@kw4Lj;UwNM9#p51V8Y{sIFXFnOg zb5%L>~eJPTgN=J-%tCO-7#OsL~Rlimx!Jp&C7Ss7%Ka zB|_~@(u7P}#QdHh?YrMP3+B*E-s+iV9LDs%syu=FgdPZ}7p=AfE(eo3nhhxz`1^eB zJq0LxB`kM znse%!(doQ4hU7IE<;2^se>wxUfb$@S%&CF?nBJ2p*6YQ0%0whd!tBOPMkuT9GjOfD z<)EA;7OQ(bICoQ5cirUkMrh>qA;A#E`5TVwVe{^l$18}bNwMzHkd+9;pLa#TnrTWG zlJ@)_vFg>t8%niu&?i9Ft5@-7x&_DV?l87*K;nDqF1HrvcF;26e!>1P;J}B1g_nnj z!_u)vBvZJQPcI!Ow*hI3gYZi!UbxmyF1rn~u<^@@$hA!FaNKPc1lqD+zc8M!t=JqH z-vb^ZRt3owgK{EL4<@ln&mmRTy}l2#di`fso#D7jI&Q#U$(%Dvm6GcOG{DzUYBzTz z(@1aPMZ_I(n{ON3EOVzf#%aEtqK+c_cje8}melO(&nq(}qe8^&{)YUjowTBqgAq>J zJ%t|7U%C(8e34m~nMSjCVtTCMH+SJ3gN6}!vihcmU0Y|PTHhKHYmgwS%7`EPw`$+T zkO^ss{)cD}#7q;-{wDr*?xl9EFGEl7fzN~Hc3?1(5`vwX)2-zG$_oO)mqPs_My-K( z2n$+Cx4WCCYA!x3mWS*mg(VNgR=Cm1yZMQ1(j*OsW}w*m@l9y@=mNR}8#>FO&4S6J zcl7;Er=tDL3+;Ft8<%Zih+BaKe9J_aqQE-z#40N(2k@^}-E)!}BHQFo2 z7ynp|{U1Yr_{RG82+xK8W8l7XhO&J3^Le3u^;>Z8u)@g|ofNzJD0OQ-?Rmw}A{PY_ ziv26W8xw*2gFZn4dDK~j(+_;H>@p7A$eh5 zUoG`Cde&A8DkLs*qOmmnm|S+Pzbh8+P}o&%1JBu!%}9%vohrVJblkVcdj={hF_uiV z9r-D=O~G#YZ1^<0Ee@-tsCr>QQ)!>w>m|thct=*198o8J?fQzU-4iwaJgCn-*8w zeK=VBh{Hn5xs~1F5`hI{FSoe=Hc!|KF-eM-Ag?-y_4>`XS=pWm_k?N`h`hKWH(N~4 z(a@HW+0II>dmCwtbebA}{#9OWq@6UWbkU@^zP9SDcD3Sd*+;>2!X$;-8p4Qc`p}Z7 z+k98fK@s-VkI28KOoi4JZ9rHioZB<Rfn;^2@qiQegJ0;%3&Ab6y>mpv$J(|_F@VVAi2G%I8%OCL;@WeGqJ1xJ^k9qteSi8rWHxtH^9@+SeqcbbyUY_>aa{J#-7|b z2NP^=I=i2wtD_iStU?%LS=F(i<$;i@A`on z#0fo$8--ha{b9H*sZBpEG_ZtWsTiM&8u%a12GQ>NyR4imrbM-VeGwzV+3gu#Yr~8K z)dt4=2BX6*l^H~HLg=OLgoc|qb&^f=wWvRTFd5ya142Jy*sCPXEh2XqNlfBm zl)MZb3lQtC3kqWRKl}m_%-DX8v)-;BPYkq&aTh$t>s6W8d}4A{z(Txyol8rYH4}u1 zLk1Wh)fU#7ikX@M`ZuOD%n6edpK&&P!%s6WQM3SB=AFyghg%(e2MuVKhps#8mndel^q%TiNSJKighBzDiQ^ebJ&79Af$F1itQoyn|)DAtE5013S`_u!E)SD(B_p5Q@vCw zEqPUelTF^IxPRbY*AbaxkV~zl+ghXc=29u&ZQxd?*$S0ilpPrwsh1 z$VYj>4~kqYWJg&&PRA0L%SB+cwjn1Mlt!k8XWExdll;B1dgT(eN5Cm4XszkC0*%u_ z0wunf2UV*gUwO7XbJbIX!Jra`J#O_z_tYMV80zmMqQNM${ z|B&+n4xw@O1^zwy;6mm2H!YBf2}Uc63OS}wO%fMf#hBml%q99hj&ze}qS|aulq3Vu zaa0LuCuhIV`G@qfT*QqHM>CG;T?;F+LY#jODtP*RbTNdxHB$A6Q;V@ zqx0xb3|nvd0VXp?*G)Aoq+1-9#x_tz&y@Ceb$3h(PH z6&-7UgNebB7!FIysj>(iqV1!jp36VQV~Pg^FHT+$0+ug&sdJwGM=)vmB?hirz^Bvt+Tl><7OE>Dy)9cs&bA^Fcqq=qBS z?f}Jo50chIV$$LLO+^RkoiZl9rZx%P5Z9m_gZhAC0)7+;i6~A2SghkZ&sir)>_BJU zk>hR8lTZA~lT^tShYA-{93Q>qdiAqM&fccg`e2L#J&gC5V)4f=7TY2P(&R^Pdt>E5 zT@46>unfWApPnS~k3|b9)(?jIJ&|Q4Dq@M3lv2|o+xoCN2Jy?3AHQi>4%Gwqwrhe| z2PURHaE=X$JI<;wQ8Ilv3VRd!G4;L0l)uqWla3&heH@D%C?Dh1KHeHD0o5(U+$mG4 z!1|A$;v~A&#{9U5idn}=w3+aS3zs~rH5oyq3^+5-2^4$7TfI7 z_I<9Padixvd{2^-YGM8@6M5xQzrh)71P^EUyR=>#=Ds8OpXvUBf9w8d{c250Lkr%i zijb(EIvevNEc%V#zHNPWu-Y*MH)o^X7N}#{XNu&!qLs(6S-{#=%8xiW1;qJwzD+@# zjk?@LVvZv4K5fjPG+^VM_)(6KL%u1DL{cA#zZJ%!{O}^hyU{IE=K8w25kxOn9(lh+ zBh_xr!_Ipv)3LBdK0&#%lcfKwfMUu`>9=*)R;JOCpdVK{276R--T}$%fyna~ z&4?MWIe;cZo*pt7XBH+nTV9c69*ekQGK}wtte(zvbaef$E5yV3qhqA_8FutElg*Ta z$?E#W{D&nUrGobx-cwdHL17WI+_i2x(8y-G?vrag22meh3DliC-aT%T%4a>c0vU0O z{;@J=rMkSga|F`T3}j@b#umD<(7#I3lX^~Jm1ok;L-Rl5HDBv+mtf@&)|zaVMBP+e zpiObD-+!?RcdI1 z!G9P6x7_ECrJ#tJ-yj;IArELooJ|TIiljktmUjh!Buhq1jq7DR{ zXFUl?y0bi%J{4_nIP`6L+_)g09xl4)1k5OZd$jbHc%UtQAJA?|`xdHxufN;2i<1m{ zTGd!7Qe9$@c0}~EnvZ2q(@4a@ko%T}9j0hv5Q(~6P-z@JN|Y$Zmtw^K`t)9qYJ>K* zAI7>>?)Z>?YwEAzBgcK)qtPo8(~B!G^w;V!wNGV({pTOr0SP*i z=AifbuEI!V!h_f98#Yc5i{U`t-^z0C9@+bpgxgD0yxY;nC^BaDn*L7ukJJnPo*Bw=44%v?`^zT(4bdrADCIL|gFJV#7)2u5yuKM_)C8r6)YK!LLR*t$4y_ z+~0TQg0flHhA1gCk8>^8>(%|(G^wnKbj%jR^YjJ6&(Ct*t3WE{ORaRRsXvaNyzyIA zG026NY_th`z!p7@ExK`NPOCJx`zwrO#_&nM>u-zF#5J zwTBTV%`Ug+RTUhp3 zzj?O>TtX_HQPTtj<3mASvKTI+(j!|D1KKBGuK2xn*tcoS9uE(d{KZL&1(2{V*b+Kr zq5!uN*`p9sAfI@SG4^uYAG5!B^+99kcr%KaH+5@)4TS~^%fw{&Yh*^G3#^i2Qxp*4 zd7Hf-7v~;n{$NXzOD=yqgHdFxo-Hlw;ld+eEd%ZHJkoILR+Pr{^J;tG46e)rM>#U< z4u`tJz-C!B{v2K=^SGn*QXY#LmuJtM=oLR%qtT`F6*}L$QCL~T^-l_MAFH>MR)_DJ zrm79wL!ABtAJ9(&>&+VpxfeWhQWp8~+~C%rj(i#z90Tw#%_s7JwO0sCiv_9>-;Lrp zp!u#IAD-`|XjX0j9%G!uACN8EhXMTcTWf=7w_Aj*;vIpi&ZO$_{ zyMRKs)=kaSl24sk+u1K)r;IV8Qt{Ab-!y)-|8N9!J7LW&!6%Ped!|Rk- zIjs>=$>Z(>!Kl;M;1fom765Yf)RDX`fsF@k+`{l>hDT_%{VlKVVyQ(D0(~PHYJA=f znWK2X8mx@e)cH2drrm^Eu(Qms8_Hufp~LZ=g&3~g_6o4gRltA7fi2ro%Hdw)5>#Pw_;~qi4vObn=xiD;!kBprHY$W<|bHD@a5Z3B;R2{Eg2? z?tGh($b>gv>T#%arg8@p-R|o*o(G6$r15ROwY|7#%j4{HGk^K36p~}@7`e)nt5KBbFMCapxp+WtsTJp<*#QtQVlu)p4=OREbt9DdBGki6k4o%KCwMe}#dZl~1Q*?A!|Kd$t^&Rmw9zEV7<{w66(=@nw1S$B%0qCRY zcAHDo_zi3A66J1IHqr_B{woKE{J#Z0&?<%^T@CvcxrPlAm2c(T(4fY>2uFUjXPh>U zF9vL|r25-g5`b<&zL1xmVwyB;fH0i+Dn=XmFzY0y6i`}5b@*J`oSE5q>?UkLH2%A& zJ$q7FLK%6K;dR(!RH!;*HEtL9 zt#pzU>L;yh`oi4JBJK03vlUFrxdg6*(NeZ)jodIEZS5%F-KhU}Pmjm%g;{VC<2#`$$5Uy$7N&hD32;9^6M??}839nE>)?>I9=JW~7gm#BkS zS`Jui>|?%61M=Y}*IwD>7D|cf2VrBfi<4SN zSYj&cRR(Wo0-}TfCgyP3hgZZQ6@Z8qo-JRPFuWz(I#(#fujFqn7 z6Y*S2fuesMb?eh^!7Z_j-)Y?VsRb98uu5|OqsU4Xg36fU6wl8=M7%h>)rl`HUG3h- zJQ{Re(M9>cv)b<4lSoGGKjeIBi=Afa{cUfuL@+y;7n+w;^Q*p^{H*JoR;<3WLXB=9 zMR8sHDvON=XMZ}9e&1GDo5Qf9Bi|{z_wkbSmufSlQ@z7#-)E1CYCkKgFd>-9zj2(N zt$^{124z*w?#&6O?*?LpTg%Utn0QgQE@-zjkD>VR1|Gc5*=ndct!hsD6R>BPI&`5V z(c{xX*B73E2=o@$B%4={?)`pKT)yU;eA{AwB-7GvsL|pR*GIcW9<=eIbyvjcsmTs? zlRNk4w?LkvwZpO>x^Z@p)JANPZ=xurV)AI4Vn&}7V98$>NAH$_jqbNp5U603&Tum$ zrG54HR!v!o59I5hAw^S;kg89PXa#5tjM^aYn7P+nOQ6(jSmfOU_Hr!79*>&z18(*e zfhd*8m6~p9hN4L*NjXD@2(`-5*DE@tyn5w&@AQyrMyoGTp}l>qv; zH`0L{E)i6ajmm(tD53VPML*_0yYu1<{D!rv4zOmAaNiu+eZ6rR;}EhX16`Q;U~e=O z7on=Z0trUduCs(ievPT#i{Qw5V5BaU1;y-KD~(j7&<38qVX_`;x3jY+U+Bj&K&we1 zF)uL)vzGhgi-HQ>H0csXs{C>QOjry*JYlN*WjEQ;BWWLk54Y2<_+1k@65d-&M4tWj z-(V)XMs(d(v6AwRXHF*>;25t2=R}?%(tXeDFZOf|X#Qb|KK_R#dYkjK{?d*KjQET; zzp*l%C5*%2#P!NB3-V5(?n|Rh_nx)@w&mf;(@YuPk&sKCd5YZ2{J1}MD09md@Lbn>N$8X|Ko-)aTUuPQl57oyiSt(-|(?T#nTOIGHq2cn<_n?`J>)i$mYyFb^7rdO+J}fU$kOD z;z_`l-DAjE;tx=F4MUSh7zL$42N}f7yeLXN>JnUB2?lgRuPu9&7bcf(cOow^<#ZBf zWc+;}K5l}n?m|Q+U9mFQ)NtlYyfs6Ou5aL6lh0E_0;QLYM}Z4=RvTAF{>WmcFB>U8 zc0xs=dcxO9r6Oq2FLy4)%1clxjwh?8WD$2PFbQ#{(K}tZ%iG@>na5WH8$GMzynU(X z39l|&nw>b!Ad7wF6H!+C*7xR7syoNQZTE9X^&WOiD(+lD@aUzPEz2-45i5)GvJT9; z@yO{9zKvw6l-V)43NZ$$jhw2``*$HM{AgPR!Yc?{beh&k7XXTR$2T?#w~|hvzjhIN z*$XV7L|1IL(C7_Z9tSONWN_sP=YA}AfAzy`6{NdG_FB%&aGzk5C$SeNO`n=i@t}#G zW}ODJE5$z)R5}AdJrOC6yI=TEu#0U@R#b0h5>3>vq77f|e}Q(Tv80j3Q_!1PX^M?% zvV3M`Nq?c8tL&HEZNA<)WDzoeS~VeopcgAUp!} z)U$0ka+S;bdJG2*pC@K4wYDc1m?(c*8kgURz&On@5k0&4d`EnJf004+e&ogXdN%5t zAv0(fb*x;l!;!f4A*X{B$3o@l3)}%zS%8#xL!r;;xNX<~%DZ^T;Oyj`sjvI1$xxPA zR`~h@BU~t)#sctA=T+`{`X&ON;0B@GZr?XP_ZX9Drw}7Y0%#g9^F)uM zKmJPOSQ)A$##cn%*I+iR0swyDdiT-jk>r;zfHmn8+qvPo#SX^^qKo@)lgogxMjgBu z{v;>+Q}*P-7)R>s<_^X*CIRR6MVxGcwbSLV=LuZdg-e^;dv%9`Rh`!k z8^YSrcE;n|uX&+U;>SLZ9hMaRElUkPM3Ns3l0ZdoIqAhZ-ht9CHyxr7vT_{hFZQW% z66r$F6@}|_4{v?RCa$qqO`>(OL;EH@lMSDhIyZ@E%?x00gL5(663xSTHC+}ZAXauV}!B~T! zkM$gB!WFus<3#Sri*ko5e~R-%rjNQT;IJfo?g5>oH*C{$3Bxqm1!=}sSt63E^}~Xe zSXTL`yWI!eOMW5bfF3BsBJE5F$=|@abIa2SS;!SXzEYR*wf>TCafF$wnZ}(5@Jl_f z#{C(=aN1?~VzYtGiFfyw??{M)hqMXV!lwwR`$oJ{p^V6wmvW$x;^ESt=7FDTTapzDLm10#e0Tn^L~$7 zkpDARZ$qHZttehdK7rQ$i-@9TFA_0C-ycj*Ha8rXGw$p#8z z(m_{QdFcGb;`2$uNcVm3DiG)fVvc_Oh_ROmLL9y0!(U~OlTRBx48p@7m?DK0C0qJNw9nxnN=P3=Kd zdUc=RvMa!^=4UNrLdjoU3}`4{sOCOos401zxwd={wt7~P)(ECTS(QskK#-Z+0O_UP zT!Wx5|EuflguSsw3b*u2PxTVuas%FtAej({Ft+y#f8k^v_hNC66-< z2UpflGlHJ7Wqo%yIB0=Y(}@`bOYUmh5p93HOx~c&=h?gUUbjW-w`%-Ch8T*htZ2pV zmqKZSOzWAB;`l48ArxPz**AalGujcOMoGm=E>AxD&+;%Q(8|3~9I)~ee&wpcNER%k z*#LBtr{lZt3F@zUWKi@?X3Fzo{gT8QK+_wOeZ}XFgz8~^`%f&8&n(7>*}ER6LC{0& zRRah8c!kN^{dzKm%9;EyQD48e-h;uOZ1vJf9dZZ67@NKM@W0G6L*)LaE(v5l$vPoL}l^aF+V|Iz(&Ro zC{SzU5n6Bhl>ODyTp#|O)BMSFfO6sSjjl`&h5tpw#@!`Dqbr4?&8&B0E#-UIoi!xO z(iUV+)y~VN{uE8xLPS_lcul2$;6_B^_J5M8ge)93{tIM;dAMm*bNBc4ZFbkVP94kj z|LCG5Sx-LOtiqPRV+eR@YpRbk16$dgg9WUwsSmr#LS5r}`F zK<6DW4ot~*`F)?dfZD%Z6gS;h>+G2D;*c=RpQyi7ejR1Gz21#!?;ffSbO8ooa(Tl> zuYMnUq?J4*&i(0tkM-L`&5TA5S!{^is=0(8JjBL*MZvH+&WJFw^U?`rP~cNrfbR&{ zO?vnfz=us<%dX2qK|}$=nQCHEtmi`c;>%uHboIvho_yM=n;C^|cF%sUcQlW#xpQN) zJw_=t%h*=S)==6~M0FhCbn>orLB}W}=ZeSsesi+D=g@y>?_dh){mR9+*~AS&q(yLI zLFv1g^6<5shBf$mrq@e#Alx<=Xy!+0J(d3Z+2bi~qTM}!5w)#zhfnHKP3@{M2N{zX zBYyIYs}DLx*?953 z_)z)$8}b1>J>3f%PLjN@x{DkU!TvH5^OR2Wa6rJQtMkKatG&RLPEmi)czq>2aPWaR;8gSRQsq^7xc*9sqzWvsO zWJ9>XX|HvGsH^X$PX)9$=EgM$f9xzaSl~u?P0@dl@Q?tNabe_Ly_o0g#|x2ogr1T1 z8!8_e9l+e``%eJTyl?aL3D*9vFOA=!B3xpIs~NkLYYS=I0vp7+Ei?U@0q_LD;A1yIEqWlxxl{e}+GOqW&c08kE)~#8*)yQ5j(# zPewF~R|iSg!2bsp8H_B*dtw*8OTf9j6rzlpmQmccoK~u@!tN4sNUKbL{$+0lbE2#OaC-8_q znJvGO&I^Ac`Iq#6v#vA`bZMcXjq-qju-u0gkKZeddvYi~m0DZ5N99~BeYvU~{_(8m zIolDQLX?+8XNZ=wp?`s)59HWCW4|Z=#zmF>m$>NvX%^^(2(J5YI&i;2F5#6_X&x3U z(Wi|ljuAh98Ld$o{fp$K`ICxDjQ5M=vZl}|FAc^#C~WrcJ_$!z)SQthNmyF$8Rvua+rUmR#sw7=Z=g4_xVj%a6spJ|M#74f(>DB7}o(K3MB6WbtqeAfkxvSLEn2Nr_1Wp7OxT}2EabfN?+KJO5OcTwbITzb1 zeN+0C{R`9d;nnM%b36ul(9lhP+e+WiWm6J7$qZ=y^12r9Z{SRu`CXT080l7JjcM0A zt!!DToGb-qk4ni`J_q!tPCvT3S9-^XlXiOD@pAZm>65Htl#Piqk}rEzX+9TAcYbk> zhSz^a-JxkBJ?agQ{h0aM$G9T)X9XME>-*u6rxECFFZ-v>Uh6cnKhDsCT7AE z3)>%cUP{jEHJP9~eS^5~kA(=0QtqPUKP5hj{kvIJfcm;d_y+$>_p&%G;V$w(TkYi2 zWa~R3w0+Dv<(x#h?S|4<`f4^yv}`uMpyUN^vA#>|&TY+`O-6COha`M1 zrl^b=QnfhhnYU!GFTt!MWb-#p;-K~UozNG>8puwc-LlE&e+3lQ$zwec^-*f*t3G5Q zcSAYFB-`7TsY&(U%#YN5#!nC`%PRI`DNqy^P}fxD@qTW4n%_sOZpSRbLzAWLtghff zw58szwk$3(_XChQ9Pns}bSdym_t$sx)ujHK=3nhb&qcu{amnB=G}(`db@?Ca1`_4l zhZJ=r4;2o7{P2=F^R=LmRizL}8PJE+hQ5f4Dc;yFFi^m5vQQMWUqrcu3Lb5;vUTT9 zO3T>IL`zk|J_H0@G2!c=ua9Qu%H=-dE%iy5RCS&z^|JQ*3yMy?+?h_cE*bAiD=qEe zf$soRTSWriWZ6C1+F)Zx-5zgoriCa|4kU)?d94X0a2Y^rBl<2y6H-!5b=7WdFJPTJ zV>#x6HkO#cg+SwM0r1e6GRvnjwY){5KUBn;(eUm&9eOlQgqGL8t#4FSo&V?`ZCm`m z=^w4j_OIgJn2%z_FDI(@-XdONi_CEIO}mJ#b>^Sbs(+vDncdavKBrmo;kH^m$V`MrarOMGNbLR)jbzIKjoPQ>CQGok*R%w|Lsf_7}U#fBZ2?K+g?(e-E zCof}OI8+^9fvd?i^Ha*Gm6t%EI>zujp|`R^2i_u*NWA8-HJf>N?)V}Vqd?`>u3~pP z%>fZ!JJZgzpWVR*%>#8`QTzaoF?Uhvd)Wp89(e{v93{(bGUoAT(ZBwOou?dsgE%V( zl6uen)%9ul&#uolaSvP{;PspPE!5rE77h9=6S#zb!L5Sn=@pZxe|q>3cf!}S|6kkZ z71r7B=;-IW+ zirJq`C^*%S54uC8FfU=3)n6_?Kf!$zcEY^zm`i7R;F9)O7qK#8gk zN*5!q3?<64G315|s;zKY54khWo6i~}I~`QX-F^8|?PP@~9@;s}Pv%A9(aZoqyISY0 z2{4<*WGHYCIjl=zDNwK!AAZF@bqr(4Eq%(fZv(JDCV-|yyi$h$&9v?)QaAXnaHSUnntmSD&|6(*f8;6dTi>D>i$|n0i zHJ1_wJ%PU|S@({9%7;9TH8H)5-&k!hTN~Gw3E#gseUZ04qNT7J7q&P@K2+8ohcH9+ zqE316+_{W99W?Y8<8KAfe%Lw@U?X)>4Bh=eT(EDJ21Cu99v%V}IDbmlR+uL7-QQgy z4Rv(i2Vc62YF<L}hvM z&*jc0W|?`uEkkM|YkSUdNNDz~n!9+zC3z+PY`@(3ZIec*o@YNGP<@HM8HJRaX}@>5 zqbHJlK^S!%Xr|bKG1Cax%P2nQDV*b7p8|^!6hgxAXtfQ~NEL+As9&kXl{S01AGKbO z3o^N|FykdGma1U7FtqP+QLtdl9$Pc>M$!j!Jt3WfB}j9%Xw-KEg!Y?K^5u3famLIIK%YZDe2BV=4QC0L1qPLK8>dP z$`=m$ORJ|fX_eGL_fFwjzu|Otaf2`x`FErrN;C=cK7Ptqfs)pRFjAzXI22kaS)j}W z`sUUpm7Myw=ZpAdU#L(KeU+DPgWJ7hCc0IPtyZMUT)UD5@@@6ifT%8G>*+ z?H^;St-ITG2l1fPVY656hiMXbYksbEN$@+u*l!=>nK+t~JCMK!Esp7QYDd~imZj<; z@n2T?RV+M(9qmr^520{X z6tuT5R@Bb&CB)p!gE1}5Iq^|bj%O)W(kSRP#$}ZBSelJ+vIauZ9xhI9l2JY!&wCaR zbr<9@ZI}$3!M>4-299PzJ4+vmh%7F=z+)eNZT%f^tO*$O=X*G0(*JW@@l;o|iVcf? zE-}3pZhFrl;dET7mM(4dj{+bLv=rwkA zO<|{1z=#HLVueKrsnz%&?6~*RKn!R$uC$}#(>e_2q1K?UG7#aO1ir5D_UCFZD000e zUNAusp!rb=jA;`Rwl1Uq2!sfzqP3$q(@s?i==YoahLo3Vo1K5i84EY!Tc(*%Gjib zES+xdC$$`{Y|LKcG>JBVlvzVmK92aSh>e()8_hi)bNwJLYFb2PV2qt=tduWVed;5nn88n?$ZQ#;py3^&tev6G$b zHUXTFd|AXTORCB+M%LsVqT0ttFnJ4Ri^xXJJ^eBgSI=&t4=-O8ddnfu<$Cj88luv; z!b&zvfQ=TgS={vv@>*}FO40jh4KH@b+K(&`F! zPqq&p%zfgw1?E)rnU_mwV~@{*z6uY9RyZz?<*o1v5Gf3ZgB!lJ!$-49-u%XTfq6^uiSYxF?5_R8>Gb95yAE~VD zV1GO=`|osG(QD54#yh`5pSayXqSI&K?S`*WFQ%W{mzRaI7*nL~7)h&+*rDDEpbAh*0n+FWbv?#W_;GkU9UhFjy(N# z!1})`XbwAiG}Sn6ot(BqNoo0g9l=6;m2S2v6DLIi{90D8mpG$TbL zuN9njj=hVk9yuhg$de_hQPE6aI!C^NS_%g#;foXv?Sp}a&tC%y#bkAC;w9nN)!`Yo2%8wkJomNlEXQlFe*#GE{>S%rV|ErHS4aF>lgody0 z5_ZWoeRE8OD22E{DjU4jVp%4!V<8|9Gv6oAv$R*F(;-RA@4 z3h=rH^IK8ek~l~%C%VqHnWMC?#A2lJ!FZN!hBzQwe3@o%TIgZE4Zy2o>))%5u2-7m zwtv%Z{i!;tCh?EF=z8x2x$O_#(eV3BQ#HznB)hZZz>1{>&-&^0vCfsVN$WY@(6vdA zJ#E@c2s?!{;#d-ifNNpErug(ov13In5x&~;G&AH-P{tQk39nDa$?z&L)0$m{ho;a) zwQkoJa}ihZg!|REWyaGh6&^ai#yZun_8uo59hmJUCMhJM?(qe+5Iwq8l&qo;ppp-G z#Up0c;rT*H{A8JYIU@Ph=f+fZy*(=tl5nWaFwww||l50Vc#mh$O zZk{G=Q+lRA2=@f&{=^6S$(JA9tTAI+2qqbgmxBdg5rd>Hln@|ousq{QRe|`YV zzF`N#XB#b}6F0aSWya-*+~Ip4#ALLY?1SZ3hqRV*pT<+rDZe)y-a-mH>K+;5?jv2a zWSykW`Yr}#yO}mw>a9mKPcwXZc>fC9e6#0Z$;L-0FLxPb)H~pQ3f){X~Fx{cJZ7$MO z%2V+KS?>UvynfNtmM#k_AP(1*=A_`^l!mIBCd5?_JObxCora0@kJnZo1-ibA?mw`i zI=3a&S^^ew_AtiKF2nk1?RSddDi82J@bJ^Ki=}Aca<4$+wx9R3KEU-Xaj$4J5p@;I zJRMD22G0wI3SKd+ry^5x#QlfHWK!|3Huz#OX_9uB`4wa+Nx@7rNo;X1PYL=4*Yh5G zOfRcOeFmDs)n^gX`#lm`BP>w=vS|!wq*++N>yuEABW(>UwfV|xkeI65R_&})8$khV zu;Oa=NwtcAFAFyE3-%-!+MaX0@%+@WvZj$aTCu?mh}n^z3%fHGQQj8FGmhj~6@?j@ zdzDBGcZZn?Whz&_?1IM{+t;vk7d`uy4Clmeao*SF!e;EN=%Rr=%4w1mTNkTrrbbNl=={lAveT~ z?Aq=34L!F-iG+-Tn=mh>Z%_GkS!5PO01u`&Plr+~qxUkdbMImynLLY_mvILZ=ou z+Wm|&q=uIDlC|V~{4EswwF! zkrb`VSeEbaA>w1C?)gdfJ2fcCPXBLFgFZh{gAT*JzocA&eBBtULukWbUJ;t#NLu3N z(}{A1M>;~|9uvW{eDBKJ(5v2ScA>-ls_r6FdFEYFWG&l(>KnC!Va^C&-qvAeXyh!= z!r}vm#UE|xuDalzU-3V%P+bI(Nhb~~o~a8lbXlG(vv=vo^Y;+5GsXHw8jPaV034wf z1brv3ae6cNkV8%)NmS?~-`CFZYXY1E4 zWBXcbz`Q8-;xOiC>6Em0a1fu)&=XPlj7OkCD$oV)A$}GM6&-qNi9@~=j(}|>&Tjhb z>_I;R)l@$j^*lgwqa%{z(ec8%UAc%rEPdymxE3xa8ZI;TAw9y2da+=`;D~Kd+6Bgn z#po#GP68f%1UGAOfshMtIQjIXU7zIw;MSLlkwAnJXr|D!s4H^+b-qpL6>;PC zyG(d--!M4o_6fteu$NW9E(AvFl-r*)+Q#BC@FU}u%baz{=`g0G~l0do3Ieox6d z(JPop-`_rKd!)$Kzen`o5!%r2zM%g><8IC1)ds8;D~2^(Tw!%)W>PBkVH;u~)>_wz z5(8^62D@O#w0}8G^C&#hIdFZXp>k>y7;-rhO(LGpZuma&P@e5Kbs7oLcXb-vSTk3a z9=atdZdXF*Tmq5P(jy_XOqEzR+EARf9Cez5M_71gV{i`iKN}M|hsmo?3wtR>LPB(XqAV&!l)A(CH zS+GBvu_AS1<8ad)NI->WfUz=hcG|h#{g6J+UXN6)0=>T7Nk=tUTxLw^{BZtSPWQyh zX+w5^F!#_twlGS)^`e>>fKYQ|`PzikHoYoLDfG4+EJBJxZ@px3mJ~uJu(rbxJ z04=@Y(i{BKwcbEc4QRGC#fTyqo02=W_o`FU44=HNcH6#l73|5E%I00?YKS(gf(dCf z8hnL?fkpcXilfs1m(>jT_DZ4pA>H~Dj_xUz4wsATdd^Ysbt_EmxtClKFeY{ zJPV0er^YuZ&|@~W5$nyJmsu!wV06P#KIBzYJDiA_O|@|+x_RfqmT)I#0h*FX^0BJB z11WO1#+O0&k=7OeNgG>xLS*6Vio|5Jfu#lk{!`|y?iC*^+==nMWzE@TOzl=no+x{! zxO|e1qnN6wdgD3e9ac|G5Md`n^WtCRRTEUIKayR0uc*#$_A`^_gz)I+mU0o3a5U$Z zZnAh%uon#KY_5mHj68W9c69=)xr$O$YW`iOOFwLhVQTyWJ)9XQ0b2o@mDx@N9a(*G zJWSB3GB)tZng#;Th(Dy2I3_)I-pf}?IYRH7^i<5$7P@#3X*HK4iXx&=^sFJB6tg=A z`Jrk!wn~F7D<#XGlr~bcfWT$W=}W0A^;Y2v3YMmsAbRtm%zLrA;px)Tjx&&A5p0p{ zDkQtpUp{R5)Fw4i=9x-7x`e9E$=z(O^Nc84{whBQ)uw?~!?oD0l1OMtS*h-=hA9nR zHCY@UIkKbu6hFR)YInA?hwW2=a$DlnQEMI*aFB=B%#@D3)A+TN%0AT-Tkkslup1Di z%xIZ2?i+53k7w;nhd*cC)0hfd$>O$*v4`JIgs+bq>EN!<>!sklGcw7LI=E~M>%0os zL9(!lrpxS&PL%6`()R1c-xt~)strD~OE~1`6*T|B2d?43sG2dGz^tAHvY1r9=p-8bGtce)%Wh#jr&=uop>Ba zgbUOC_48~HFP;xxjQi&fJ}&>M8!m$3uibE!4mA%=l3%qcJ$_^(@G&v;7Juy|wPMQX z$MBw@!D&!6J_ypIj=-?XkDazu2to$FsPN10UPmO+8gCw*9`kZdk7tdwk)7sX`Qlno z;npBAl#pKYQ26km!0r0rI`P>uk#oW#SM%rWaQ69$!_l@0w8MW#t@k}Q!_kFelWPU(Bbw5Fxe(snYd4QKM$q92~QQ4IgK_g7MOjpfz zqV4kYHRTGf>F4jl>7wvms>CETDPgJN>tB}1!xNQpFhCLE)4g$hEgjB-yzzt51ggZ| z{!egl>#cco;1N(-3DdJ86p|qDF?6HEyyl_bOMXvdUsI;Xp%EF8Jw#)_WIi^0n~t%9 z?~PP30b3teLY`#!{pwKDl3;6e`DX;v0f6y7ZU$F=g-P?zqE|{A|w{k^LK>P5?Qm2n&4DdKL50m1Uj`#vsg<%B(Ft?e=?Q zyAn&rV&`D!WZ#0*f-EXxW`CNnHgC!O+z27U#pgIP9r)MxipFO#u$hsU>e&peM*_?- zA)#s+25W+>Xdr|XEB1p5fPBG3{Fn3?r&~w<%e?gm5na&vrM~w;0tq~*YTQ2sTzDp} LC{_Bz;Prn2f={)I literal 0 HcmV?d00001 diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..f49791b --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'MinePanel' diff --git a/src/main/java/de/winniepat/minePanel/MinePanel.java b/src/main/java/de/winniepat/minePanel/MinePanel.java new file mode 100644 index 0000000..f545c2b --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/MinePanel.java @@ -0,0 +1,212 @@ +package de.winniepat.minePanel; + +import de.winniepat.minePanel.auth.*; +import de.winniepat.minePanel.config.WebPanelConfig; +import de.winniepat.minePanel.extensions.*; +import de.winniepat.minePanel.integrations.*; +import de.winniepat.minePanel.lifecycle.PluginLifecycleSupport; +import de.winniepat.minePanel.logs.*; +import de.winniepat.minePanel.persistence.*; +import de.winniepat.minePanel.util.ServerSchedulerBridge; +import de.winniepat.minePanel.web.*; +import net.kyori.adventure.text.logger.slf4j.ComponentLogger; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.plugin.java.JavaPlugin; + +import java.nio.file.Path; +import java.time.Instant; + +public final class MinePanel extends JavaPlugin { + + private Database database; + private WebPanelServer webPanelServer; + private DiscordWebhookService discordWebhookService; + private LogRepository logRepository; + private PanelLogger panelLogger; + private PlayerActivityRepository playerActivityRepository; + private JoinLeaveEventRepository joinLeaveEventRepository; + private ExtensionManager extensionManager; + private ExtensionCommandRegistry extensionCommandRegistry; + private WebAssetService webAssetService; + private ExtensionSettingsRepository extensionSettingsRepository; + private ServerSchedulerBridge schedulerBridge; + + MiniMessage mm = MiniMessage.miniMessage(); + ComponentLogger componentLogger = this.getComponentLogger(); + + private record StartupContext( + UserRepository userRepository, + KnownPlayerRepository knownPlayerRepository, + SessionService sessionService, + PasswordHasher passwordHasher, + ServerLogService serverLogService, + BootstrapService bootstrapService, + JoinLeaveEventRepository joinLeaveEventRepository, + OAuthAccountRepository oAuthAccountRepository, + OAuthStateRepository oAuthStateRepository, + ExtensionManager extensionManager, + WebAssetService webAssetService, + ExtensionSettingsRepository extensionSettingsRepository + ) {} + + @Override + public void onEnable() { + saveDefaultConfig(); + PluginLifecycleSupport.configureThirdPartyStartupLogging(); + this.schedulerBridge = new ServerSchedulerBridge(this); + + WebPanelConfig panelConfig = WebPanelConfig.fromConfig(getConfig()); + StartupContext startupContext = initializeStartupContext(panelConfig); + + PluginLifecycleSupport.announceMinePanelBanner(this, componentLogger, mm); + PluginLifecycleSupport.announceBootstrapToken(startupContext.bootstrapService(), componentLogger, mm); + + PluginLifecycleSupport.registerPluginListeners( + this, + panelLogger, + startupContext.knownPlayerRepository(), + playerActivityRepository, + joinLeaveEventRepository + ); + PluginLifecycleSupport.synchronizeKnownPlayers(getServer(), startupContext.knownPlayerRepository(), playerActivityRepository); + startWebPanel(panelConfig, startupContext); + + componentLogger.info("{}{}:{}", mm.deserialize("MinePanel available at: "), "http://" + panelConfig.host(), panelConfig.port()); + panelLogger.log("SYSTEM", "PLUGIN", "MinePanel plugin started"); + } + + private StartupContext initializeStartupContext(WebPanelConfig panelConfig) { + this.database = new Database(getDataFolder().toPath().resolve("panel.db")); + this.database.initialize(); + + UserRepository userRepository = new UserRepository(database); + int normalizedOwners = userRepository.demoteExtraOwnersToAdmin(); + if (normalizedOwners > 0) { + getLogger().warning("Detected multiple owner accounts. Demoted " + normalizedOwners + " extra owner account(s) to ADMIN."); + } + + this.logRepository = new LogRepository(database); + KnownPlayerRepository knownPlayerRepository = new KnownPlayerRepository(database); + this.playerActivityRepository = new PlayerActivityRepository(database); + this.joinLeaveEventRepository = new JoinLeaveEventRepository(database); + + DiscordWebhookRepository discordWebhookRepository = new DiscordWebhookRepository(database); + this.discordWebhookService = new DiscordWebhookService(getLogger(), discordWebhookRepository); + this.panelLogger = new PanelLogger(logRepository, discordWebhookService); + this.logRepository.clearLogs(); + + SessionService sessionService = new SessionService(database, panelConfig.sessionTtlMinutes()); + PasswordHasher passwordHasher = new PasswordHasher(); + ServerLogService serverLogService = new ServerLogService(getDataFolder().toPath()); + BootstrapService bootstrapService = new BootstrapService(userRepository, panelConfig.bootstrapTokenLength()); + OAuthAccountRepository oAuthAccountRepository = new OAuthAccountRepository(database); + OAuthStateRepository oAuthStateRepository = new OAuthStateRepository(database); + this.extensionSettingsRepository = new ExtensionSettingsRepository(database); + this.webAssetService = initializeWebAssets(); + + this.extensionManager = initializeExtensions(knownPlayerRepository); + + return new StartupContext( + userRepository, + knownPlayerRepository, + sessionService, + passwordHasher, + serverLogService, + bootstrapService, + joinLeaveEventRepository, + oAuthAccountRepository, + oAuthStateRepository, + extensionManager, + webAssetService, + extensionSettingsRepository + ); + } + + private WebAssetService initializeWebAssets() { + WebAssetService service = new WebAssetService(this, getDataFolder().toPath().resolve("web")); + service.ensureSeeded(); + return service; + } + + private ExtensionManager initializeExtensions(KnownPlayerRepository knownPlayerRepository) { + this.extensionCommandRegistry = new BukkitExtensionCommandRegistry(this); + ExtensionContext context = new ExtensionContext( + this, + database, + panelLogger, + knownPlayerRepository, + playerActivityRepository, + extensionCommandRegistry, + schedulerBridge + ); + ExtensionManager manager = new ExtensionManager(this, context); + + + Path extensionDirectory = getDataFolder().toPath().resolve("extensions"); + manager.loadFromDirectory(extensionDirectory); + manager.enableAll(); + return manager; + } + + public ServerSchedulerBridge schedulerBridge() { + return schedulerBridge; + } + + private void startWebPanel(WebPanelConfig panelConfig, StartupContext startupContext) { + this.webPanelServer = new WebPanelServer( + this, + panelConfig, + startupContext.userRepository(), + startupContext.sessionService(), + startupContext.passwordHasher(), + this.logRepository, + startupContext.knownPlayerRepository(), + playerActivityRepository, + discordWebhookService, + panelLogger, + startupContext.serverLogService(), + startupContext.bootstrapService(), + startupContext.joinLeaveEventRepository(), + startupContext.oAuthAccountRepository(), + startupContext.oAuthStateRepository(), + startupContext.extensionManager(), + startupContext.webAssetService(), + startupContext.extensionSettingsRepository() + ); + this.webPanelServer.start(); + } + + @Override + public void onDisable() { + if (webPanelServer != null) { + webPanelServer.stop(); + } + + if (extensionManager != null) { + extensionManager.disableAll(); + } + + if (playerActivityRepository != null) { + long now = Instant.now().toEpochMilli(); + getServer().getOnlinePlayers().forEach(player -> + playerActivityRepository.onQuit(player.getUniqueId(), now) + ); + } + + if (panelLogger != null) { + panelLogger.log("SYSTEM", "PLUGIN", "MinePanel plugin stopping"); + } + + if (logRepository != null) { + PluginLifecycleSupport.exportPanelLogsToServerLogsDirectory(getDataFolder().toPath(), logRepository, getLogger()); + } + + if (discordWebhookService != null) { + discordWebhookService.shutdown(); + } + if (database != null) { + database.close(); + } + } + +} diff --git a/src/main/java/de/winniepat/minePanel/auth/PasswordHasher.java b/src/main/java/de/winniepat/minePanel/auth/PasswordHasher.java new file mode 100644 index 0000000..9ca59d0 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/auth/PasswordHasher.java @@ -0,0 +1,15 @@ +package de.winniepat.minePanel.auth; + +import org.mindrot.jbcrypt.BCrypt; + +public final class PasswordHasher { + + public String hash(String rawPassword) { + return BCrypt.hashpw(rawPassword, BCrypt.gensalt(12)); + } + + public boolean verify(String rawPassword, String hashedPassword) { + return BCrypt.checkpw(rawPassword, hashedPassword); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/auth/SessionService.java b/src/main/java/de/winniepat/minePanel/auth/SessionService.java new file mode 100644 index 0000000..e7a8bc5 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/auth/SessionService.java @@ -0,0 +1,108 @@ +package de.winniepat.minePanel.auth; + +import de.winniepat.minePanel.persistence.Database; + +import java.security.SecureRandom; +import java.sql.*; +import java.time.Instant; +import java.util.*; + +public final class SessionService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final Database database; + private final int sessionTtlMinutes; + + public SessionService(Database database, int sessionTtlMinutes) { + this.database = database; + this.sessionTtlMinutes = sessionTtlMinutes; + } + + public String createSession(long userId) { + cleanupExpiredSessions(); + + String token = generateToken(); + long now = Instant.now().toEpochMilli(); + long expiresAt = now + (sessionTtlMinutes * 60_000L); + + String sql = "INSERT INTO sessions(token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, token); + statement.setLong(2, userId); + statement.setLong(3, now); + statement.setLong(4, expiresAt); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not create session", exception); + } + return token; + } + + public Optional resolveUserId(String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + + long now = Instant.now().toEpochMilli(); + String sql = "SELECT user_id FROM sessions WHERE token = ? AND expires_at > ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, token); + statement.setLong(2, now); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return Optional.of(resultSet.getLong("user_id")); + } + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not resolve session", exception); + } + return Optional.empty(); + } + + public void deleteSession(String token) { + if (token == null || token.isBlank()) { + return; + } + + String sql = "DELETE FROM sessions WHERE token = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, token); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not delete session", exception); + } + } + + public void deleteSessionsByUserId(long userId) { + String sql = "DELETE FROM sessions WHERE user_id = ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, userId); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not delete sessions for user", exception); + } + } + + public void cleanupExpiredSessions() { + String sql = "DELETE FROM sessions WHERE expires_at <= ?"; + try (Connection connection = database.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, Instant.now().toEpochMilli()); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not clean expired sessions", exception); + } + } + + private String generateToken() { + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} + diff --git a/src/main/java/de/winniepat/minePanel/config/WebPanelConfig.java b/src/main/java/de/winniepat/minePanel/config/WebPanelConfig.java new file mode 100644 index 0000000..ceec608 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/config/WebPanelConfig.java @@ -0,0 +1,73 @@ +package de.winniepat.minePanel.config; + +import org.bukkit.configuration.file.FileConfiguration; + +public record WebPanelConfig( + String host, + int port, + int sessionTtlMinutes, + int bootstrapTokenLength, + int oauthStateTtlMinutes, + OAuthProviderConfig googleOAuth, + OAuthProviderConfig discordOAuth +) { + + public static WebPanelConfig fromConfig(FileConfiguration config) { + String host = config.getString("web.host", "127.0.0.1"); + int port = config.getInt("web.port", 8080); + int sessionTtlMinutes = Math.max(5, config.getInt("web.sessionTtlMinutes", 120)); + int bootstrapTokenLength = Math.max(16, config.getInt("security.bootstrapTokenLength", 32)); + int oauthStateTtlMinutes = Math.max(2, config.getInt("integrations.oauth.stateTtlMinutes", 10)); + + OAuthProviderConfig googleOAuth = parseProvider(config, "google", "MINEPANEL_OAUTH_GOOGLE"); + OAuthProviderConfig discordOAuth = parseProvider(config, "discord", "MINEPANEL_OAUTH_DISCORD"); + + return new WebPanelConfig(host, port, sessionTtlMinutes, bootstrapTokenLength, oauthStateTtlMinutes, googleOAuth, discordOAuth); + } + + private static OAuthProviderConfig parseProvider(FileConfiguration config, String key, String envPrefix) { + String section = "integrations.oauth." + key + "."; + boolean enabled = config.getBoolean(section + "enabled", false); + String clientId = envOrDefault(envPrefix + "_CLIENT_ID", config.getString(section + "clientId", "")); + String clientSecret = envOrDefault(envPrefix + "_CLIENT_SECRET", config.getString(section + "clientSecret", "")); + String redirectUri = envOrDefault(envPrefix + "_REDIRECT_URI", config.getString(section + "redirectUri", "")); + return new OAuthProviderConfig(enabled, sanitize(clientId), sanitize(clientSecret), sanitize(redirectUri)); + } + + private static String envOrDefault(String key, String fallback) { + String value = System.getenv(key); + if (value != null && !value.isBlank()) { + return value; + } + return fallback; + } + + private static String sanitize(String value) { + return value == null ? "" : value.trim(); + } + + public OAuthProviderConfig oauthProvider(String provider) { + if (provider == null) { + return OAuthProviderConfig.disabled(); + } + String normalized = provider.trim().toLowerCase(); + if ("google".equals(normalized)) { + return googleOAuth; + } + if ("discord".equals(normalized)) { + return discordOAuth; + } + return OAuthProviderConfig.disabled(); + } + + public record OAuthProviderConfig(boolean enabled, String clientId, String clientSecret, String redirectUri) { + public static OAuthProviderConfig disabled() { + return new OAuthProviderConfig(false, "", "", ""); + } + + public boolean configured() { + return enabled && !clientId.isBlank() && !clientSecret.isBlank() && !redirectUri.isBlank(); + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/BukkitExtensionCommandRegistry.java b/src/main/java/de/winniepat/minePanel/extensions/BukkitExtensionCommandRegistry.java new file mode 100644 index 0000000..0e17b94 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/BukkitExtensionCommandRegistry.java @@ -0,0 +1,200 @@ +package de.winniepat.minePanel.extensions; + +import de.winniepat.minePanel.MinePanel; +import org.bukkit.command.*; +import org.bukkit.plugin.Plugin; +import org.bukkit.command.PluginIdentifiableCommand; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.*; + +public final class BukkitExtensionCommandRegistry implements ExtensionCommandRegistry { + + private final MinePanel plugin; + private final Map> commandsByExtensionId = new HashMap<>(); + + public BukkitExtensionCommandRegistry(MinePanel plugin) { + this.plugin = plugin; + } + + @Override + public synchronized boolean register( + String extensionId, + String name, + String description, + String usage, + String permission, + List aliases, + CommandExecutor executor, + TabCompleter tabCompleter + ) { + if (executor == null || isBlank(extensionId) || isBlank(name)) { + return false; + } + + String normalizedExtensionId = extensionId.trim().toLowerCase(Locale.ROOT); + String normalizedName = name.trim().toLowerCase(Locale.ROOT); + + CommandMap commandMap = getCommandMap(); + if (commandMap == null) { + plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command map unavailable"); + return false; + } + + if (commandMap.getCommand(normalizedName) != null) { + plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command already exists"); + return false; + } + + RuntimeExtensionCommand command = new RuntimeExtensionCommand( + plugin, + normalizedName, + defaultString(description, "Extension command"), + defaultString(usage, "/" + normalizedName), + aliases == null ? List.of() : aliases, + executor, + tabCompleter + ); + if (!isBlank(permission)) { + command.setPermission(permission.trim()); + } + + boolean registered = commandMap.register("minepanelext", command); + if (!registered) { + plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command map rejected registration"); + return false; + } + + commandsByExtensionId.computeIfAbsent(normalizedExtensionId, ignored -> new ArrayList<>()).add(command); + return true; + } + + @Override + public synchronized void unregisterForExtension(String extensionId) { + if (isBlank(extensionId)) { + return; + } + + String normalizedExtensionId = extensionId.trim().toLowerCase(Locale.ROOT); + List commands = commandsByExtensionId.remove(normalizedExtensionId); + if (commands == null || commands.isEmpty()) { + return; + } + + CommandMap commandMap = getCommandMap(); + if (commandMap == null) { + return; + } + + Map knownCommands = getKnownCommands(commandMap); + for (RuntimeExtensionCommand command : commands) { + command.unregister(commandMap); + removeCommandEntries(knownCommands, command); + } + } + + @Override + public synchronized void unregisterAll() { + List extensionIds = new ArrayList<>(commandsByExtensionId.keySet()); + for (String extensionId : extensionIds) { + unregisterForExtension(extensionId); + } + commandsByExtensionId.clear(); + } + + private void removeCommandEntries(Map knownCommands, RuntimeExtensionCommand command) { + if (knownCommands == null || knownCommands.isEmpty()) { + return; + } + + Iterator> iterator = knownCommands.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (entry.getValue() == command) { + iterator.remove(); + } + } + } + + @SuppressWarnings("unchecked") + private Map getKnownCommands(CommandMap commandMap) { + try { + Field knownCommandsField = commandMap.getClass().getDeclaredField("knownCommands"); + knownCommandsField.setAccessible(true); + Object value = knownCommandsField.get(commandMap); + if (value instanceof Map map) { + return (Map) map; + } + } catch (Exception ignored) { + // Best effort cleanup. + } + return Collections.emptyMap(); + } + + private CommandMap getCommandMap() { + try { + Method getCommandMapMethod = plugin.getServer().getClass().getMethod("getCommandMap"); + Object value = getCommandMapMethod.invoke(plugin.getServer()); + if (value instanceof CommandMap map) { + return map; + } + } catch (Exception ignored) { + // Supported on CraftServer-based implementations. + } + return null; + } + + private String defaultString(String value, String fallback) { + return isBlank(value) ? fallback : value.trim(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private static final class RuntimeExtensionCommand extends Command implements PluginIdentifiableCommand { + + private final Plugin plugin; + private final CommandExecutor executor; + private final TabCompleter tabCompleter; + + private RuntimeExtensionCommand( + Plugin plugin, + String name, + String description, + String usage, + List aliases, + CommandExecutor executor, + TabCompleter tabCompleter + ) { + super(name, description, usage, aliases == null ? List.of() : aliases); + this.plugin = plugin; + this.executor = executor; + this.tabCompleter = tabCompleter; + } + + @Override + public boolean execute(CommandSender sender, String commandLabel, String[] args) { + if (testPermission(sender)) { + return executor.onCommand(sender, this, commandLabel, args); + } + return true; + } + + @Override + public List tabComplete(CommandSender sender, String alias, String[] args) { + if (tabCompleter == null) { + return List.of(); + } + List completions = tabCompleter.onTabComplete(sender, this, alias, args); + return completions == null ? List.of() : completions; + } + + @Override + public Plugin getPlugin() { + return plugin; + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionCommandRegistry.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionCommandRegistry.java new file mode 100644 index 0000000..db152fb --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionCommandRegistry.java @@ -0,0 +1,37 @@ +package de.winniepat.minePanel.extensions; + +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.TabCompleter; + +import java.util.List; + +public interface ExtensionCommandRegistry { + + boolean register( + String extensionId, + String name, + String description, + String usage, + String permission, + List aliases, + CommandExecutor executor, + TabCompleter tabCompleter + ); + + default boolean register( + String extensionId, + String name, + String description, + String usage, + String permission, + List aliases, + CommandExecutor executor + ) { + return register(extensionId, name, description, usage, permission, aliases, executor, null); + } + + void unregisterForExtension(String extensionId); + + void unregisterAll(); +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionConfigurable.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionConfigurable.java new file mode 100644 index 0000000..90e50d9 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionConfigurable.java @@ -0,0 +1,15 @@ +package de.winniepat.minePanel.extensions; + +/** + * Optional extension contract for runtime settings updates from the panel. + */ +public interface ExtensionConfigurable { + + /** + * Applies updated settings JSON immediately at runtime. + * + * @param settingsJson extension settings JSON payload + */ + void onSettingsUpdated(String settingsJson); +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionContext.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionContext.java new file mode 100644 index 0000000..01e7e83 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionContext.java @@ -0,0 +1,20 @@ +package de.winniepat.minePanel.extensions; + +import de.winniepat.minePanel.MinePanel; +import de.winniepat.minePanel.logs.PanelLogger; +import de.winniepat.minePanel.persistence.Database; +import de.winniepat.minePanel.persistence.KnownPlayerRepository; +import de.winniepat.minePanel.persistence.PlayerActivityRepository; +import de.winniepat.minePanel.util.ServerSchedulerBridge; + +public record ExtensionContext( + MinePanel plugin, + Database database, + PanelLogger panelLogger, + KnownPlayerRepository knownPlayerRepository, + PlayerActivityRepository playerActivityRepository, + ExtensionCommandRegistry commandRegistry, + ServerSchedulerBridge schedulerBridge +) { +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionManager.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionManager.java new file mode 100644 index 0000000..741ee61 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionManager.java @@ -0,0 +1,566 @@ +package de.winniepat.minePanel.extensions; + +import de.winniepat.minePanel.MinePanel; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Stream; + +public final class ExtensionManager { + + private final MinePanel plugin; + private final ExtensionContext context; + private final List loadedExtensions = new ArrayList<>(); + private final List classLoaders = new ArrayList<>(); + private final Set loadedIds = new HashSet<>(); + private final Set loadedArtifacts = new HashSet<>(); + private final Map extensionSourceById = new HashMap<>(); + private Path extensionsDirectory; + private ExtensionWebRegistry activeWebRegistry; + + public ExtensionManager(MinePanel plugin, ExtensionContext context) { + this.plugin = plugin; + this.context = context; + } + + public void registerBuiltIn(MinePanelExtension extension) { + registerLoadedExtension(extension, "built-in"); + } + + public synchronized void loadFromDirectory(Path extensionsDirectory) { + this.extensionsDirectory = extensionsDirectory; + try { + Files.createDirectories(extensionsDirectory); + } catch (IOException exception) { + plugin.getLogger().warning("Could not create extensions directory: " + exception.getMessage()); + return; + } + + cleanupDuplicateArtifactsOnStartup(extensionsDirectory); + + try (Stream files = Files.list(extensionsDirectory)) { + files + .filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) + .sorted(this::compareJarsNewestFirst) + .forEach(path -> loadJarExtensions(path, null)); + } catch (IOException exception) { + plugin.getLogger().warning("Could not scan extensions directory: " + exception.getMessage()); + } + } + + public synchronized ReloadResult reloadNewFromDirectory(ExtensionWebRegistry webRegistry) { + if (webRegistry != null) { + this.activeWebRegistry = webRegistry; + } + + if (extensionsDirectory == null) { + return new ReloadResult(0, List.of(), List.of("extensions_directory_unavailable")); + } + + List newlyLoaded = new ArrayList<>(); + List warnings = new ArrayList<>(); + int scanned = 0; + + try (Stream files = Files.list(extensionsDirectory)) { + List jars = files + .filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) + .sorted(this::compareJarsNewestFirst) + .toList(); + + for (Path jarFile : jars) { + scanned++; + String artifactName = jarFile.getFileName().toString(); + if (loadedArtifacts.contains(artifactName)) { + continue; + } + loadJarExtensions(jarFile, newlyLoaded); + } + } catch (IOException exception) { + warnings.add("could_not_scan_extensions_directory"); + plugin.getLogger().warning("Could not scan extensions directory: " + exception.getMessage()); + } + + for (MinePanelExtension extension : newlyLoaded) { + try { + extension.onEnable(); + plugin.getLogger().info("Enabled extension " + extension.id() + " (" + extension.displayName() + ")"); + } catch (Exception exception) { + warnings.add("enable_failed:" + extension.id()); + context.commandRegistry().unregisterForExtension(extension.id()); + plugin.getLogger().warning("Could not enable extension " + extension.id() + ": " + exception.getMessage()); + } + + if (activeWebRegistry != null) { + try { + extension.registerWebRoutes(activeWebRegistry); + } catch (Exception exception) { + warnings.add("route_registration_failed:" + extension.id()); + plugin.getLogger().warning("Could not register web routes for extension " + extension.id() + ": " + exception.getMessage()); + } + } + } + + List loadedExtensionIds = newlyLoaded.stream().map(MinePanelExtension::id).toList(); + return new ReloadResult(scanned, loadedExtensionIds, warnings); + } + + public synchronized List> installedExtensions() { + List> installed = new ArrayList<>(); + for (MinePanelExtension extension : loadedExtensions) { + String id = extension.id() == null ? "" : extension.id(); + String source = extensionSourceById.getOrDefault(id.toLowerCase(Locale.ROOT), "unknown"); + installed.add(Map.of( + "id", id, + "displayName", extension.displayName() == null ? id : extension.displayName(), + "source", source + )); + } + + installed.sort(Comparator.comparing(item -> String.valueOf(item.get("id")), String.CASE_INSENSITIVE_ORDER)); + return installed; + } + + public synchronized List> availableArtifacts() { + List artifacts = scanArtifactFileNames(); + Set loadedArtifacts = new HashSet<>(); + for (String source : extensionSourceById.values()) { + if (source != null && source.toLowerCase(Locale.ROOT).endsWith(".jar")) { + loadedArtifacts.add(source); + } + } + + List> available = new ArrayList<>(); + for (String artifact : artifacts) { + available.add(Map.of( + "fileName", artifact, + "loaded", loadedArtifacts.contains(artifact) + )); + } + return available; + } + + public void enableAll() { + for (MinePanelExtension extension : loadedExtensions) { + try { + extension.onEnable(); + plugin.getLogger().info("Enabled extension " + extension.id() + " (" + extension.displayName() + ")"); + } catch (Exception exception) { + context.commandRegistry().unregisterForExtension(extension.id()); + plugin.getLogger().warning("Could not enable extension " + extension.id() + ": " + exception.getMessage()); + } + } + } + + public synchronized void registerWebRoutes(ExtensionWebRegistry webRegistry) { + this.activeWebRegistry = webRegistry; + for (MinePanelExtension extension : loadedExtensions) { + try { + extension.registerWebRoutes(webRegistry); + } catch (Exception exception) { + plugin.getLogger().warning("Could not register web routes for extension " + extension.id() + ": " + exception.getMessage()); + } + } + } + + public List navigationTabs() { + List tabs = new ArrayList<>(); + for (MinePanelExtension extension : loadedExtensions) { + try { + tabs.addAll(extension.navigationTabs()); + } catch (Exception exception) { + plugin.getLogger().warning("Could not read tabs for extension " + extension.id() + ": " + exception.getMessage()); + } + } + return tabs; + } + + public synchronized boolean supportsSettings(String extensionId) { + String normalizedId = normalizeExtensionId(extensionId); + if (normalizedId.isBlank()) { + return false; + } + + for (MinePanelExtension extension : loadedExtensions) { + if (!normalizeExtensionId(extension.id()).equals(normalizedId)) { + continue; + } + return extension instanceof ExtensionConfigurable; + } + + return false; + } + + public synchronized boolean applySettings(String extensionId, String settingsJson) { + String normalizedId = normalizeExtensionId(extensionId); + if (normalizedId.isBlank()) { + return false; + } + + for (MinePanelExtension extension : loadedExtensions) { + if (!normalizeExtensionId(extension.id()).equals(normalizedId)) { + continue; + } + + if (!(extension instanceof ExtensionConfigurable configurable)) { + return false; + } + + try { + configurable.onSettingsUpdated(settingsJson == null ? "{}" : settingsJson); + return true; + } catch (Exception exception) { + plugin.getLogger().warning("Could not apply live settings for extension " + extension.id() + ": " + exception.getMessage()); + return false; + } + } + + return false; + } + + public synchronized void disableAll() { + ListIterator iterator = loadedExtensions.listIterator(loadedExtensions.size()); + while (iterator.hasPrevious()) { + MinePanelExtension extension = iterator.previous(); + try { + extension.onDisable(); + } catch (Exception exception) { + plugin.getLogger().warning("Could not disable extension " + extension.id() + ": " + exception.getMessage()); + } finally { + context.commandRegistry().unregisterForExtension(extension.id()); + } + } + + context.commandRegistry().unregisterAll(); + + for (URLClassLoader classLoader : classLoaders) { + try { + classLoader.close(); + } catch (IOException ignored) { + // Best effort cleanup. + } + } + classLoaders.clear(); + loadedArtifacts.clear(); + loadedIds.clear(); + extensionSourceById.clear(); + loadedExtensions.clear(); + } + + private void loadJarExtensions(Path jarFile, List newlyLoaded) { + URLClassLoader classLoader = null; + try { + URL url = jarFile.toUri().toURL(); + classLoader = new URLClassLoader(new URL[]{url}, plugin.getClass().getClassLoader()); + + ServiceLoader serviceLoader = ServiceLoader.load(MinePanelExtension.class, classLoader); + int found = 0; + int registered = 0; + for (MinePanelExtension extension : serviceLoader) { + found++; + if (registerLoadedExtension(extension, jarFile.getFileName().toString())) { + registered++; + if (newlyLoaded != null) { + newlyLoaded.add(extension); + } + } + } + + if (found == 0) { + plugin.getLogger().warning("No MinePanel extension entry found in " + jarFile.getFileName() + ". Add META-INF/services/" + MinePanelExtension.class.getName()); + } + + if (registered > 0) { + classLoaders.add(classLoader); + loadedArtifacts.add(jarFile.getFileName().toString()); + classLoader = null; + } + } catch (Exception exception) { + plugin.getLogger().warning("Could not load extension jar " + jarFile.getFileName() + ": " + exception.getMessage()); + } finally { + if (classLoader != null) { + try { + classLoader.close(); + } catch (IOException ignored) { + // Best effort cleanup. + } + } + } + } + + private boolean registerLoadedExtension(MinePanelExtension extension, String source) { + if (extension == null || extension.id() == null || extension.id().isBlank()) { + plugin.getLogger().warning("Skipping extension with invalid id from " + source); + return false; + } + + String id = extension.id().trim().toLowerCase(Locale.ROOT); + if (!loadedIds.add(id)) { + plugin.getLogger().warning("Skipping duplicate extension id: " + extension.id()); + return false; + } + + try { + extension.onLoad(context); + loadedExtensions.add(extension); + extensionSourceById.put(id, source); + plugin.getLogger().info("Loaded extension " + extension.id() + " from " + source); + return true; + } catch (Exception exception) { + loadedIds.remove(id); + plugin.getLogger().warning("Could not initialize extension " + extension.id() + ": " + exception.getMessage()); + return false; + } + } + + public record ReloadResult(int scannedArtifactCount, List loadedExtensionIds, List warnings) { + } + + private List scanArtifactFileNames() { + if (extensionsDirectory == null) { + return List.of(); + } + + try (Stream files = Files.list(extensionsDirectory)) { + return files + .filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) + .map(path -> path.getFileName().toString()) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } catch (IOException exception) { + plugin.getLogger().warning("Could not read extension artifacts: " + exception.getMessage()); + return List.of(); + } + } + + private int compareJarsNewestFirst(Path left, Path right) { + long leftModified = lastModifiedMillis(left); + long rightModified = lastModifiedMillis(right); + int byModified = Long.compare(rightModified, leftModified); + if (byModified != 0) { + return byModified; + } + + String leftName = left.getFileName().toString().toLowerCase(Locale.ROOT); + String rightName = right.getFileName().toString().toLowerCase(Locale.ROOT); + return rightName.compareTo(leftName); + } + + private long lastModifiedMillis(Path file) { + try { + return Files.getLastModifiedTime(file).toMillis(); + } catch (IOException ignored) { + return 0L; + } + } + + private void cleanupDuplicateArtifactsOnStartup(Path extensionDirectory) { + List jars; + try (Stream files = Files.list(extensionDirectory)) { + jars = files + .filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) + .toList(); + } catch (IOException exception) { + plugin.getLogger().warning("Could not inspect extension artifacts for startup cleanup: " + exception.getMessage()); + return; + } + + if (jars.size() < 2) { + return; + } + + Map keepByKey = new HashMap<>(); + Map keepVersionByKey = new HashMap<>(); + + for (Path jar : jars) { + String fileName = jar.getFileName().toString(); + String key = extensionKeyFromArtifact(fileName); + if (key.isBlank()) { + continue; + } + + ArtifactVersionToken currentVersion = parseArtifactVersion(fileName); + Path keptPath = keepByKey.get(key); + ArtifactVersionToken keptVersion = keepVersionByKey.get(key); + + if (keptPath == null || keptVersion == null) { + keepByKey.put(key, jar); + keepVersionByKey.put(key, currentVersion); + continue; + } + + int compare = compareArtifactVersions(currentVersion, keptVersion); + if (compare > 0 || (compare == 0 && compareJarsNewestFirst(jar, keptPath) < 0)) { + keepByKey.put(key, jar); + keepVersionByKey.put(key, currentVersion); + } + } + + for (Path jar : jars) { + String fileName = jar.getFileName().toString(); + String key = extensionKeyFromArtifact(fileName); + if (key.isBlank()) { + continue; + } + + Path kept = keepByKey.get(key); + if (kept == null || kept.equals(jar)) { + continue; + } + + try { + Files.deleteIfExists(jar); + plugin.getLogger().info("Removed old extension artifact on startup: " + fileName + " (kept " + kept.getFileName() + ")"); + } catch (IOException exception) { + plugin.getLogger().warning("Could not delete old extension artifact " + fileName + " on startup: " + exception.getMessage()); + } + } + } + + private String extensionKeyFromArtifact(String fileName) { + if (fileName == null || fileName.isBlank()) { + return ""; + } + + String normalized = fileName.trim().toLowerCase(Locale.ROOT); + if (!normalized.endsWith(".jar") || !normalized.startsWith("minepanel-extension-")) { + return ""; + } + + normalized = normalized.substring(0, normalized.length() - 4); + normalized = normalized.substring("minepanel-extension-".length()); + + int alphaSplit = normalized.lastIndexOf("-alpha-"); + if (alphaSplit > 0) { + normalized = normalized.substring(0, alphaSplit); + } else { + int semverSplit = normalized.lastIndexOf('-'); + if (semverSplit > 0) { + String suffix = normalized.substring(semverSplit + 1); + if (isNumericVersionSuffix(suffix)) { + normalized = normalized.substring(0, semverSplit); + } + } + } + + return normalized.replaceAll("[^a-z0-9]", ""); + } + + private String normalizeExtensionId(String rawId) { + if (rawId == null || rawId.isBlank()) { + return ""; + } + + return rawId.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + } + + private ArtifactVersionToken parseArtifactVersion(String fileName) { + if (fileName == null || fileName.isBlank()) { + return ArtifactVersionToken.unknown(); + } + + String normalized = fileName.trim().toLowerCase(Locale.ROOT); + if (normalized.endsWith(".jar")) { + normalized = normalized.substring(0, normalized.length() - 4); + } + + int alphaSplit = normalized.lastIndexOf("-alpha-"); + if (alphaSplit > 0) { + String buildRaw = normalized.substring(alphaSplit + "-alpha-".length()); + try { + return ArtifactVersionToken.alpha(Integer.parseInt(buildRaw)); + } catch (NumberFormatException ignored) { + return ArtifactVersionToken.unknown(); + } + } + + int semverSplit = normalized.lastIndexOf('-'); + if (semverSplit > 0) { + String suffix = normalized.substring(semverSplit + 1); + if (isNumericVersionSuffix(suffix)) { + List parts = new ArrayList<>(); + for (String part : suffix.split("\\.")) { + if (part.isBlank()) { + continue; + } + try { + parts.add(Integer.parseInt(part)); + } catch (NumberFormatException ignored) { + return ArtifactVersionToken.unknown(); + } + } + if (!parts.isEmpty()) { + return ArtifactVersionToken.semver(parts); + } + } + } + + return ArtifactVersionToken.unknown(); + } + + private boolean isNumericVersionSuffix(String value) { + if (value == null || value.isBlank()) { + return false; + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (!Character.isDigit(c) && c != '.') { + return false; + } + } + return true; + } + + private int compareArtifactVersions(ArtifactVersionToken left, ArtifactVersionToken right) { + if (left.kind() != right.kind()) { + return Integer.compare(left.kind().priority, right.kind().priority); + } + + if (left.kind() == ArtifactVersionKind.ALPHA) { + return Integer.compare(left.alphaBuild(), right.alphaBuild()); + } + + if (left.kind() == ArtifactVersionKind.SEMVER) { + int max = Math.max(left.semverParts().size(), right.semverParts().size()); + for (int i = 0; i < max; i++) { + int l = i < left.semverParts().size() ? left.semverParts().get(i) : 0; + int r = i < right.semverParts().size() ? right.semverParts().get(i) : 0; + if (l != r) { + return Integer.compare(l, r); + } + } + } + + return 0; + } + + private enum ArtifactVersionKind { + UNKNOWN(0), + ALPHA(1), + SEMVER(2); + + private final int priority; + + ArtifactVersionKind(int priority) { + this.priority = priority; + } + } + + private record ArtifactVersionToken(ArtifactVersionKind kind, int alphaBuild, List semverParts) { + private static ArtifactVersionToken unknown() { + return new ArtifactVersionToken(ArtifactVersionKind.UNKNOWN, -1, List.of()); + } + + private static ArtifactVersionToken alpha(int build) { + return new ArtifactVersionToken(ArtifactVersionKind.ALPHA, build, List.of()); + } + + private static ArtifactVersionToken semver(List parts) { + return new ArtifactVersionToken(ArtifactVersionKind.SEMVER, -1, parts == null ? List.of() : List.copyOf(parts)); + } + } +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionNavigationTab.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionNavigationTab.java new file mode 100644 index 0000000..32e38f5 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionNavigationTab.java @@ -0,0 +1,9 @@ +package de.winniepat.minePanel.extensions; + +public record ExtensionNavigationTab( + String category, + String label, + String path +) { +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionRouteHandler.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionRouteHandler.java new file mode 100644 index 0000000..28ec6b3 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionRouteHandler.java @@ -0,0 +1,11 @@ +package de.winniepat.minePanel.extensions; + +import de.winniepat.minePanel.users.PanelUser; +import spark.Request; +import spark.Response; + +@FunctionalInterface +public interface ExtensionRouteHandler { + Object handle(Request request, Response response, PanelUser user) throws Exception; +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/ExtensionWebRegistry.java b/src/main/java/de/winniepat/minePanel/extensions/ExtensionWebRegistry.java new file mode 100644 index 0000000..f31ea3a --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/ExtensionWebRegistry.java @@ -0,0 +1,16 @@ +package de.winniepat.minePanel.extensions; + +import de.winniepat.minePanel.users.PanelPermission; +import spark.Response; + +import java.util.Map; + +public interface ExtensionWebRegistry { + + void get(String path, PanelPermission permission, ExtensionRouteHandler handler); + + void post(String path, PanelPermission permission, ExtensionRouteHandler handler); + + String json(Response response, int status, Map payload); +} + diff --git a/src/main/java/de/winniepat/minePanel/extensions/MinePanelExtension.java b/src/main/java/de/winniepat/minePanel/extensions/MinePanelExtension.java new file mode 100644 index 0000000..1b97435 --- /dev/null +++ b/src/main/java/de/winniepat/minePanel/extensions/MinePanelExtension.java @@ -0,0 +1,88 @@ +package de.winniepat.minePanel.extensions; + +import java.util.List; + +/** + * Service Provider Interface (SPI) for MinePanel extensions. + *

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

+ *

+ * Typical lifecycle: + *

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

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

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

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

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

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

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

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

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

Account Settings

+
+

Linked OAuth Accounts

+

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

+

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

Announcements

+

Broadcast rotating server announcements to all online players.

+ +
+

Status

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

Configuration

+ + + +
+ + + +
+

+
+ +
+

Create Message

+ +

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

+
+ +
+
+ +
+

Messages

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

Bans

+

Current bans: 0

+ +
+

Ban List

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

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

Console

+
+ +
+ +
+

Panel Logs + Chat + Commands

+

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

Discord Webhook

+

Configure what MinePanel sends to your Discord channel.

+ +
+

Webhook Settings

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

+
+
+ +
+

Template Variables

+

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

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

Extension Config

+

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

+ +
+
+
+

Installed Extensions

+
+
+
+

Settings

+
+
+ + + +
+

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

Extensions

+

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

+ + + +
+ + + +
+ +
+

Installed Extensions (0)

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

Available Extensions on GitHub (0)

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

Maintenance

+

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

+ +
+

Status

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

Operators and players with bypass permission can still join.

+
+ +
+

Controls

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

Overview

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

TPS Over Time (Last Hour)

+
+ +
+
+ +
+
+

Memory Over Time

+
+ +
+
+
+

CPU Over Time

+
+ +
+
+
+ +
+
+

Join Heatmap (Day x Hour)

+
+ +
+
+
+

Leave Heatmap (Day x Hour)

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

Players

+

All players that ever joined: 0

+ +
+
+
+

Player List

+ +
+
    +
    + +
    +

    Player Profile

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

    Plugins

    +

    Installed server plugins: 0

    + +
    + + +
    + +
    +

    Installed Plugins

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

    Reports

    +
    + + +
    + +
    +

    Player Reports

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

    Resources

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

    Server Runtime

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

    Themes

    +

    Pick a preset or customize panel colors.

    + +
    +

    Presets

    +
    + + +
    +
    + +
    +

    Custom Colors

    +
    +
    + + +
    +

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

    Tickets

    +
    + + +
    + +
    +

    Player Tickets

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

    Users

    +
    +

    User Management

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

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

    Whitelist

    +

    View, add, and remove whitelisted players.

    + +
    +

    Whitelist Status

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

    Add Player

    +
    + + + +
    + +
    + +
    +

    Whitelisted Players (0)

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

    World Backups

    +

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

    + +
    +

    Create Backup

    +
    + + + +
    +

    +
    + +
    +

    Overworld Backups

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

    Nether Backups

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

    End Backups

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

    MinePanel

    +

    Sign in to access logs and admin tools.

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

    First Launch Setup

    +

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

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