first commit

This commit is contained in:
Patrick
2026-05-01 18:46:17 +02:00
commit 61ae38701e
104 changed files with 20058 additions and 0 deletions
@@ -0,0 +1,212 @@
package de.winniepat.minePanel;
import de.winniepat.minePanel.auth.*;
import de.winniepat.minePanel.config.WebPanelConfig;
import de.winniepat.minePanel.extensions.*;
import de.winniepat.minePanel.integrations.*;
import de.winniepat.minePanel.lifecycle.PluginLifecycleSupport;
import de.winniepat.minePanel.logs.*;
import de.winniepat.minePanel.persistence.*;
import de.winniepat.minePanel.util.ServerSchedulerBridge;
import de.winniepat.minePanel.web.*;
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.plugin.java.JavaPlugin;
import java.nio.file.Path;
import java.time.Instant;
public final class MinePanel extends JavaPlugin {
private Database database;
private WebPanelServer webPanelServer;
private DiscordWebhookService discordWebhookService;
private LogRepository logRepository;
private PanelLogger panelLogger;
private PlayerActivityRepository playerActivityRepository;
private JoinLeaveEventRepository joinLeaveEventRepository;
private ExtensionManager extensionManager;
private ExtensionCommandRegistry extensionCommandRegistry;
private WebAssetService webAssetService;
private ExtensionSettingsRepository extensionSettingsRepository;
private ServerSchedulerBridge schedulerBridge;
MiniMessage mm = MiniMessage.miniMessage();
ComponentLogger componentLogger = this.getComponentLogger();
private record StartupContext(
UserRepository userRepository,
KnownPlayerRepository knownPlayerRepository,
SessionService sessionService,
PasswordHasher passwordHasher,
ServerLogService serverLogService,
BootstrapService bootstrapService,
JoinLeaveEventRepository joinLeaveEventRepository,
OAuthAccountRepository oAuthAccountRepository,
OAuthStateRepository oAuthStateRepository,
ExtensionManager extensionManager,
WebAssetService webAssetService,
ExtensionSettingsRepository extensionSettingsRepository
) {}
@Override
public void onEnable() {
saveDefaultConfig();
PluginLifecycleSupport.configureThirdPartyStartupLogging();
this.schedulerBridge = new ServerSchedulerBridge(this);
WebPanelConfig panelConfig = WebPanelConfig.fromConfig(getConfig());
StartupContext startupContext = initializeStartupContext(panelConfig);
PluginLifecycleSupport.announceMinePanelBanner(this, componentLogger, mm);
PluginLifecycleSupport.announceBootstrapToken(startupContext.bootstrapService(), componentLogger, mm);
PluginLifecycleSupport.registerPluginListeners(
this,
panelLogger,
startupContext.knownPlayerRepository(),
playerActivityRepository,
joinLeaveEventRepository
);
PluginLifecycleSupport.synchronizeKnownPlayers(getServer(), startupContext.knownPlayerRepository(), playerActivityRepository);
startWebPanel(panelConfig, startupContext);
componentLogger.info("{}{}:{}", mm.deserialize("<aqua>MinePanel available at: </aqua>"), "http://" + panelConfig.host(), panelConfig.port());
panelLogger.log("SYSTEM", "PLUGIN", "MinePanel plugin started");
}
private StartupContext initializeStartupContext(WebPanelConfig panelConfig) {
this.database = new Database(getDataFolder().toPath().resolve("panel.db"));
this.database.initialize();
UserRepository userRepository = new UserRepository(database);
int normalizedOwners = userRepository.demoteExtraOwnersToAdmin();
if (normalizedOwners > 0) {
getLogger().warning("Detected multiple owner accounts. Demoted " + normalizedOwners + " extra owner account(s) to ADMIN.");
}
this.logRepository = new LogRepository(database);
KnownPlayerRepository knownPlayerRepository = new KnownPlayerRepository(database);
this.playerActivityRepository = new PlayerActivityRepository(database);
this.joinLeaveEventRepository = new JoinLeaveEventRepository(database);
DiscordWebhookRepository discordWebhookRepository = new DiscordWebhookRepository(database);
this.discordWebhookService = new DiscordWebhookService(getLogger(), discordWebhookRepository);
this.panelLogger = new PanelLogger(logRepository, discordWebhookService);
this.logRepository.clearLogs();
SessionService sessionService = new SessionService(database, panelConfig.sessionTtlMinutes());
PasswordHasher passwordHasher = new PasswordHasher();
ServerLogService serverLogService = new ServerLogService(getDataFolder().toPath());
BootstrapService bootstrapService = new BootstrapService(userRepository, panelConfig.bootstrapTokenLength());
OAuthAccountRepository oAuthAccountRepository = new OAuthAccountRepository(database);
OAuthStateRepository oAuthStateRepository = new OAuthStateRepository(database);
this.extensionSettingsRepository = new ExtensionSettingsRepository(database);
this.webAssetService = initializeWebAssets();
this.extensionManager = initializeExtensions(knownPlayerRepository);
return new StartupContext(
userRepository,
knownPlayerRepository,
sessionService,
passwordHasher,
serverLogService,
bootstrapService,
joinLeaveEventRepository,
oAuthAccountRepository,
oAuthStateRepository,
extensionManager,
webAssetService,
extensionSettingsRepository
);
}
private WebAssetService initializeWebAssets() {
WebAssetService service = new WebAssetService(this, getDataFolder().toPath().resolve("web"));
service.ensureSeeded();
return service;
}
private ExtensionManager initializeExtensions(KnownPlayerRepository knownPlayerRepository) {
this.extensionCommandRegistry = new BukkitExtensionCommandRegistry(this);
ExtensionContext context = new ExtensionContext(
this,
database,
panelLogger,
knownPlayerRepository,
playerActivityRepository,
extensionCommandRegistry,
schedulerBridge
);
ExtensionManager manager = new ExtensionManager(this, context);
Path extensionDirectory = getDataFolder().toPath().resolve("extensions");
manager.loadFromDirectory(extensionDirectory);
manager.enableAll();
return manager;
}
public ServerSchedulerBridge schedulerBridge() {
return schedulerBridge;
}
private void startWebPanel(WebPanelConfig panelConfig, StartupContext startupContext) {
this.webPanelServer = new WebPanelServer(
this,
panelConfig,
startupContext.userRepository(),
startupContext.sessionService(),
startupContext.passwordHasher(),
this.logRepository,
startupContext.knownPlayerRepository(),
playerActivityRepository,
discordWebhookService,
panelLogger,
startupContext.serverLogService(),
startupContext.bootstrapService(),
startupContext.joinLeaveEventRepository(),
startupContext.oAuthAccountRepository(),
startupContext.oAuthStateRepository(),
startupContext.extensionManager(),
startupContext.webAssetService(),
startupContext.extensionSettingsRepository()
);
this.webPanelServer.start();
}
@Override
public void onDisable() {
if (webPanelServer != null) {
webPanelServer.stop();
}
if (extensionManager != null) {
extensionManager.disableAll();
}
if (playerActivityRepository != null) {
long now = Instant.now().toEpochMilli();
getServer().getOnlinePlayers().forEach(player ->
playerActivityRepository.onQuit(player.getUniqueId(), now)
);
}
if (panelLogger != null) {
panelLogger.log("SYSTEM", "PLUGIN", "MinePanel plugin stopping");
}
if (logRepository != null) {
PluginLifecycleSupport.exportPanelLogsToServerLogsDirectory(getDataFolder().toPath(), logRepository, getLogger());
}
if (discordWebhookService != null) {
discordWebhookService.shutdown();
}
if (database != null) {
database.close();
}
}
}
@@ -0,0 +1,15 @@
package de.winniepat.minePanel.auth;
import org.mindrot.jbcrypt.BCrypt;
public final class PasswordHasher {
public String hash(String rawPassword) {
return BCrypt.hashpw(rawPassword, BCrypt.gensalt(12));
}
public boolean verify(String rawPassword, String hashedPassword) {
return BCrypt.checkpw(rawPassword, hashedPassword);
}
}
@@ -0,0 +1,108 @@
package de.winniepat.minePanel.auth;
import de.winniepat.minePanel.persistence.Database;
import java.security.SecureRandom;
import java.sql.*;
import java.time.Instant;
import java.util.*;
public final class SessionService {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final Database database;
private final int sessionTtlMinutes;
public SessionService(Database database, int sessionTtlMinutes) {
this.database = database;
this.sessionTtlMinutes = sessionTtlMinutes;
}
public String createSession(long userId) {
cleanupExpiredSessions();
String token = generateToken();
long now = Instant.now().toEpochMilli();
long expiresAt = now + (sessionTtlMinutes * 60_000L);
String sql = "INSERT INTO sessions(token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, token);
statement.setLong(2, userId);
statement.setLong(3, now);
statement.setLong(4, expiresAt);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not create session", exception);
}
return token;
}
public Optional<Long> resolveUserId(String token) {
if (token == null || token.isBlank()) {
return Optional.empty();
}
long now = Instant.now().toEpochMilli();
String sql = "SELECT user_id FROM sessions WHERE token = ? AND expires_at > ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, token);
statement.setLong(2, now);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(resultSet.getLong("user_id"));
}
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not resolve session", exception);
}
return Optional.empty();
}
public void deleteSession(String token) {
if (token == null || token.isBlank()) {
return;
}
String sql = "DELETE FROM sessions WHERE token = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, token);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not delete session", exception);
}
}
public void deleteSessionsByUserId(long userId) {
String sql = "DELETE FROM sessions WHERE user_id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not delete sessions for user", exception);
}
}
public void cleanupExpiredSessions() {
String sql = "DELETE FROM sessions WHERE expires_at <= ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, Instant.now().toEpochMilli());
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not clean expired sessions", exception);
}
}
private String generateToken() {
byte[] bytes = new byte[32];
SECURE_RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
@@ -0,0 +1,73 @@
package de.winniepat.minePanel.config;
import org.bukkit.configuration.file.FileConfiguration;
public record WebPanelConfig(
String host,
int port,
int sessionTtlMinutes,
int bootstrapTokenLength,
int oauthStateTtlMinutes,
OAuthProviderConfig googleOAuth,
OAuthProviderConfig discordOAuth
) {
public static WebPanelConfig fromConfig(FileConfiguration config) {
String host = config.getString("web.host", "127.0.0.1");
int port = config.getInt("web.port", 8080);
int sessionTtlMinutes = Math.max(5, config.getInt("web.sessionTtlMinutes", 120));
int bootstrapTokenLength = Math.max(16, config.getInt("security.bootstrapTokenLength", 32));
int oauthStateTtlMinutes = Math.max(2, config.getInt("integrations.oauth.stateTtlMinutes", 10));
OAuthProviderConfig googleOAuth = parseProvider(config, "google", "MINEPANEL_OAUTH_GOOGLE");
OAuthProviderConfig discordOAuth = parseProvider(config, "discord", "MINEPANEL_OAUTH_DISCORD");
return new WebPanelConfig(host, port, sessionTtlMinutes, bootstrapTokenLength, oauthStateTtlMinutes, googleOAuth, discordOAuth);
}
private static OAuthProviderConfig parseProvider(FileConfiguration config, String key, String envPrefix) {
String section = "integrations.oauth." + key + ".";
boolean enabled = config.getBoolean(section + "enabled", false);
String clientId = envOrDefault(envPrefix + "_CLIENT_ID", config.getString(section + "clientId", ""));
String clientSecret = envOrDefault(envPrefix + "_CLIENT_SECRET", config.getString(section + "clientSecret", ""));
String redirectUri = envOrDefault(envPrefix + "_REDIRECT_URI", config.getString(section + "redirectUri", ""));
return new OAuthProviderConfig(enabled, sanitize(clientId), sanitize(clientSecret), sanitize(redirectUri));
}
private static String envOrDefault(String key, String fallback) {
String value = System.getenv(key);
if (value != null && !value.isBlank()) {
return value;
}
return fallback;
}
private static String sanitize(String value) {
return value == null ? "" : value.trim();
}
public OAuthProviderConfig oauthProvider(String provider) {
if (provider == null) {
return OAuthProviderConfig.disabled();
}
String normalized = provider.trim().toLowerCase();
if ("google".equals(normalized)) {
return googleOAuth;
}
if ("discord".equals(normalized)) {
return discordOAuth;
}
return OAuthProviderConfig.disabled();
}
public record OAuthProviderConfig(boolean enabled, String clientId, String clientSecret, String redirectUri) {
public static OAuthProviderConfig disabled() {
return new OAuthProviderConfig(false, "", "", "");
}
public boolean configured() {
return enabled && !clientId.isBlank() && !clientSecret.isBlank() && !redirectUri.isBlank();
}
}
}
@@ -0,0 +1,200 @@
package de.winniepat.minePanel.extensions;
import de.winniepat.minePanel.MinePanel;
import org.bukkit.command.*;
import org.bukkit.plugin.Plugin;
import org.bukkit.command.PluginIdentifiableCommand;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.*;
public final class BukkitExtensionCommandRegistry implements ExtensionCommandRegistry {
private final MinePanel plugin;
private final Map<String, List<RuntimeExtensionCommand>> commandsByExtensionId = new HashMap<>();
public BukkitExtensionCommandRegistry(MinePanel plugin) {
this.plugin = plugin;
}
@Override
public synchronized boolean register(
String extensionId,
String name,
String description,
String usage,
String permission,
List<String> aliases,
CommandExecutor executor,
TabCompleter tabCompleter
) {
if (executor == null || isBlank(extensionId) || isBlank(name)) {
return false;
}
String normalizedExtensionId = extensionId.trim().toLowerCase(Locale.ROOT);
String normalizedName = name.trim().toLowerCase(Locale.ROOT);
CommandMap commandMap = getCommandMap();
if (commandMap == null) {
plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command map unavailable");
return false;
}
if (commandMap.getCommand(normalizedName) != null) {
plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command already exists");
return false;
}
RuntimeExtensionCommand command = new RuntimeExtensionCommand(
plugin,
normalizedName,
defaultString(description, "Extension command"),
defaultString(usage, "/" + normalizedName),
aliases == null ? List.of() : aliases,
executor,
tabCompleter
);
if (!isBlank(permission)) {
command.setPermission(permission.trim());
}
boolean registered = commandMap.register("minepanelext", command);
if (!registered) {
plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command map rejected registration");
return false;
}
commandsByExtensionId.computeIfAbsent(normalizedExtensionId, ignored -> new ArrayList<>()).add(command);
return true;
}
@Override
public synchronized void unregisterForExtension(String extensionId) {
if (isBlank(extensionId)) {
return;
}
String normalizedExtensionId = extensionId.trim().toLowerCase(Locale.ROOT);
List<RuntimeExtensionCommand> commands = commandsByExtensionId.remove(normalizedExtensionId);
if (commands == null || commands.isEmpty()) {
return;
}
CommandMap commandMap = getCommandMap();
if (commandMap == null) {
return;
}
Map<String, Command> knownCommands = getKnownCommands(commandMap);
for (RuntimeExtensionCommand command : commands) {
command.unregister(commandMap);
removeCommandEntries(knownCommands, command);
}
}
@Override
public synchronized void unregisterAll() {
List<String> extensionIds = new ArrayList<>(commandsByExtensionId.keySet());
for (String extensionId : extensionIds) {
unregisterForExtension(extensionId);
}
commandsByExtensionId.clear();
}
private void removeCommandEntries(Map<String, Command> knownCommands, RuntimeExtensionCommand command) {
if (knownCommands == null || knownCommands.isEmpty()) {
return;
}
Iterator<Map.Entry<String, Command>> iterator = knownCommands.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Command> entry = iterator.next();
if (entry.getValue() == command) {
iterator.remove();
}
}
}
@SuppressWarnings("unchecked")
private Map<String, Command> getKnownCommands(CommandMap commandMap) {
try {
Field knownCommandsField = commandMap.getClass().getDeclaredField("knownCommands");
knownCommandsField.setAccessible(true);
Object value = knownCommandsField.get(commandMap);
if (value instanceof Map<?, ?> map) {
return (Map<String, Command>) map;
}
} catch (Exception ignored) {
// Best effort cleanup.
}
return Collections.emptyMap();
}
private CommandMap getCommandMap() {
try {
Method getCommandMapMethod = plugin.getServer().getClass().getMethod("getCommandMap");
Object value = getCommandMapMethod.invoke(plugin.getServer());
if (value instanceof CommandMap map) {
return map;
}
} catch (Exception ignored) {
// Supported on CraftServer-based implementations.
}
return null;
}
private String defaultString(String value, String fallback) {
return isBlank(value) ? fallback : value.trim();
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private static final class RuntimeExtensionCommand extends Command implements PluginIdentifiableCommand {
private final Plugin plugin;
private final CommandExecutor executor;
private final TabCompleter tabCompleter;
private RuntimeExtensionCommand(
Plugin plugin,
String name,
String description,
String usage,
List<String> aliases,
CommandExecutor executor,
TabCompleter tabCompleter
) {
super(name, description, usage, aliases == null ? List.of() : aliases);
this.plugin = plugin;
this.executor = executor;
this.tabCompleter = tabCompleter;
}
@Override
public boolean execute(CommandSender sender, String commandLabel, String[] args) {
if (testPermission(sender)) {
return executor.onCommand(sender, this, commandLabel, args);
}
return true;
}
@Override
public List<String> tabComplete(CommandSender sender, String alias, String[] args) {
if (tabCompleter == null) {
return List.of();
}
List<String> completions = tabCompleter.onTabComplete(sender, this, alias, args);
return completions == null ? List.of() : completions;
}
@Override
public Plugin getPlugin() {
return plugin;
}
}
}
@@ -0,0 +1,37 @@
package de.winniepat.minePanel.extensions;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.TabCompleter;
import java.util.List;
public interface ExtensionCommandRegistry {
boolean register(
String extensionId,
String name,
String description,
String usage,
String permission,
List<String> aliases,
CommandExecutor executor,
TabCompleter tabCompleter
);
default boolean register(
String extensionId,
String name,
String description,
String usage,
String permission,
List<String> aliases,
CommandExecutor executor
) {
return register(extensionId, name, description, usage, permission, aliases, executor, null);
}
void unregisterForExtension(String extensionId);
void unregisterAll();
}
@@ -0,0 +1,15 @@
package de.winniepat.minePanel.extensions;
/**
* Optional extension contract for runtime settings updates from the panel.
*/
public interface ExtensionConfigurable {
/**
* Applies updated settings JSON immediately at runtime.
*
* @param settingsJson extension settings JSON payload
*/
void onSettingsUpdated(String settingsJson);
}
@@ -0,0 +1,20 @@
package de.winniepat.minePanel.extensions;
import de.winniepat.minePanel.MinePanel;
import de.winniepat.minePanel.logs.PanelLogger;
import de.winniepat.minePanel.persistence.Database;
import de.winniepat.minePanel.persistence.KnownPlayerRepository;
import de.winniepat.minePanel.persistence.PlayerActivityRepository;
import de.winniepat.minePanel.util.ServerSchedulerBridge;
public record ExtensionContext(
MinePanel plugin,
Database database,
PanelLogger panelLogger,
KnownPlayerRepository knownPlayerRepository,
PlayerActivityRepository playerActivityRepository,
ExtensionCommandRegistry commandRegistry,
ServerSchedulerBridge schedulerBridge
) {
}
@@ -0,0 +1,566 @@
package de.winniepat.minePanel.extensions;
import de.winniepat.minePanel.MinePanel;
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
public final class ExtensionManager {
private final MinePanel plugin;
private final ExtensionContext context;
private final List<MinePanelExtension> loadedExtensions = new ArrayList<>();
private final List<URLClassLoader> classLoaders = new ArrayList<>();
private final Set<String> loadedIds = new HashSet<>();
private final Set<String> loadedArtifacts = new HashSet<>();
private final Map<String, String> extensionSourceById = new HashMap<>();
private Path extensionsDirectory;
private ExtensionWebRegistry activeWebRegistry;
public ExtensionManager(MinePanel plugin, ExtensionContext context) {
this.plugin = plugin;
this.context = context;
}
public void registerBuiltIn(MinePanelExtension extension) {
registerLoadedExtension(extension, "built-in");
}
public synchronized void loadFromDirectory(Path extensionsDirectory) {
this.extensionsDirectory = extensionsDirectory;
try {
Files.createDirectories(extensionsDirectory);
} catch (IOException exception) {
plugin.getLogger().warning("Could not create extensions directory: " + exception.getMessage());
return;
}
cleanupDuplicateArtifactsOnStartup(extensionsDirectory);
try (Stream<Path> files = Files.list(extensionsDirectory)) {
files
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
.sorted(this::compareJarsNewestFirst)
.forEach(path -> loadJarExtensions(path, null));
} catch (IOException exception) {
plugin.getLogger().warning("Could not scan extensions directory: " + exception.getMessage());
}
}
public synchronized ReloadResult reloadNewFromDirectory(ExtensionWebRegistry webRegistry) {
if (webRegistry != null) {
this.activeWebRegistry = webRegistry;
}
if (extensionsDirectory == null) {
return new ReloadResult(0, List.of(), List.of("extensions_directory_unavailable"));
}
List<MinePanelExtension> newlyLoaded = new ArrayList<>();
List<String> warnings = new ArrayList<>();
int scanned = 0;
try (Stream<Path> files = Files.list(extensionsDirectory)) {
List<Path> jars = files
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
.sorted(this::compareJarsNewestFirst)
.toList();
for (Path jarFile : jars) {
scanned++;
String artifactName = jarFile.getFileName().toString();
if (loadedArtifacts.contains(artifactName)) {
continue;
}
loadJarExtensions(jarFile, newlyLoaded);
}
} catch (IOException exception) {
warnings.add("could_not_scan_extensions_directory");
plugin.getLogger().warning("Could not scan extensions directory: " + exception.getMessage());
}
for (MinePanelExtension extension : newlyLoaded) {
try {
extension.onEnable();
plugin.getLogger().info("Enabled extension " + extension.id() + " (" + extension.displayName() + ")");
} catch (Exception exception) {
warnings.add("enable_failed:" + extension.id());
context.commandRegistry().unregisterForExtension(extension.id());
plugin.getLogger().warning("Could not enable extension " + extension.id() + ": " + exception.getMessage());
}
if (activeWebRegistry != null) {
try {
extension.registerWebRoutes(activeWebRegistry);
} catch (Exception exception) {
warnings.add("route_registration_failed:" + extension.id());
plugin.getLogger().warning("Could not register web routes for extension " + extension.id() + ": " + exception.getMessage());
}
}
}
List<String> loadedExtensionIds = newlyLoaded.stream().map(MinePanelExtension::id).toList();
return new ReloadResult(scanned, loadedExtensionIds, warnings);
}
public synchronized List<Map<String, Object>> installedExtensions() {
List<Map<String, Object>> installed = new ArrayList<>();
for (MinePanelExtension extension : loadedExtensions) {
String id = extension.id() == null ? "" : extension.id();
String source = extensionSourceById.getOrDefault(id.toLowerCase(Locale.ROOT), "unknown");
installed.add(Map.of(
"id", id,
"displayName", extension.displayName() == null ? id : extension.displayName(),
"source", source
));
}
installed.sort(Comparator.comparing(item -> String.valueOf(item.get("id")), String.CASE_INSENSITIVE_ORDER));
return installed;
}
public synchronized List<Map<String, Object>> availableArtifacts() {
List<String> artifacts = scanArtifactFileNames();
Set<String> loadedArtifacts = new HashSet<>();
for (String source : extensionSourceById.values()) {
if (source != null && source.toLowerCase(Locale.ROOT).endsWith(".jar")) {
loadedArtifacts.add(source);
}
}
List<Map<String, Object>> available = new ArrayList<>();
for (String artifact : artifacts) {
available.add(Map.of(
"fileName", artifact,
"loaded", loadedArtifacts.contains(artifact)
));
}
return available;
}
public void enableAll() {
for (MinePanelExtension extension : loadedExtensions) {
try {
extension.onEnable();
plugin.getLogger().info("Enabled extension " + extension.id() + " (" + extension.displayName() + ")");
} catch (Exception exception) {
context.commandRegistry().unregisterForExtension(extension.id());
plugin.getLogger().warning("Could not enable extension " + extension.id() + ": " + exception.getMessage());
}
}
}
public synchronized void registerWebRoutes(ExtensionWebRegistry webRegistry) {
this.activeWebRegistry = webRegistry;
for (MinePanelExtension extension : loadedExtensions) {
try {
extension.registerWebRoutes(webRegistry);
} catch (Exception exception) {
plugin.getLogger().warning("Could not register web routes for extension " + extension.id() + ": " + exception.getMessage());
}
}
}
public List<ExtensionNavigationTab> navigationTabs() {
List<ExtensionNavigationTab> tabs = new ArrayList<>();
for (MinePanelExtension extension : loadedExtensions) {
try {
tabs.addAll(extension.navigationTabs());
} catch (Exception exception) {
plugin.getLogger().warning("Could not read tabs for extension " + extension.id() + ": " + exception.getMessage());
}
}
return tabs;
}
public synchronized boolean supportsSettings(String extensionId) {
String normalizedId = normalizeExtensionId(extensionId);
if (normalizedId.isBlank()) {
return false;
}
for (MinePanelExtension extension : loadedExtensions) {
if (!normalizeExtensionId(extension.id()).equals(normalizedId)) {
continue;
}
return extension instanceof ExtensionConfigurable;
}
return false;
}
public synchronized boolean applySettings(String extensionId, String settingsJson) {
String normalizedId = normalizeExtensionId(extensionId);
if (normalizedId.isBlank()) {
return false;
}
for (MinePanelExtension extension : loadedExtensions) {
if (!normalizeExtensionId(extension.id()).equals(normalizedId)) {
continue;
}
if (!(extension instanceof ExtensionConfigurable configurable)) {
return false;
}
try {
configurable.onSettingsUpdated(settingsJson == null ? "{}" : settingsJson);
return true;
} catch (Exception exception) {
plugin.getLogger().warning("Could not apply live settings for extension " + extension.id() + ": " + exception.getMessage());
return false;
}
}
return false;
}
public synchronized void disableAll() {
ListIterator<MinePanelExtension> iterator = loadedExtensions.listIterator(loadedExtensions.size());
while (iterator.hasPrevious()) {
MinePanelExtension extension = iterator.previous();
try {
extension.onDisable();
} catch (Exception exception) {
plugin.getLogger().warning("Could not disable extension " + extension.id() + ": " + exception.getMessage());
} finally {
context.commandRegistry().unregisterForExtension(extension.id());
}
}
context.commandRegistry().unregisterAll();
for (URLClassLoader classLoader : classLoaders) {
try {
classLoader.close();
} catch (IOException ignored) {
// Best effort cleanup.
}
}
classLoaders.clear();
loadedArtifacts.clear();
loadedIds.clear();
extensionSourceById.clear();
loadedExtensions.clear();
}
private void loadJarExtensions(Path jarFile, List<MinePanelExtension> newlyLoaded) {
URLClassLoader classLoader = null;
try {
URL url = jarFile.toUri().toURL();
classLoader = new URLClassLoader(new URL[]{url}, plugin.getClass().getClassLoader());
ServiceLoader<MinePanelExtension> serviceLoader = ServiceLoader.load(MinePanelExtension.class, classLoader);
int found = 0;
int registered = 0;
for (MinePanelExtension extension : serviceLoader) {
found++;
if (registerLoadedExtension(extension, jarFile.getFileName().toString())) {
registered++;
if (newlyLoaded != null) {
newlyLoaded.add(extension);
}
}
}
if (found == 0) {
plugin.getLogger().warning("No MinePanel extension entry found in " + jarFile.getFileName() + ". Add META-INF/services/" + MinePanelExtension.class.getName());
}
if (registered > 0) {
classLoaders.add(classLoader);
loadedArtifacts.add(jarFile.getFileName().toString());
classLoader = null;
}
} catch (Exception exception) {
plugin.getLogger().warning("Could not load extension jar " + jarFile.getFileName() + ": " + exception.getMessage());
} finally {
if (classLoader != null) {
try {
classLoader.close();
} catch (IOException ignored) {
// Best effort cleanup.
}
}
}
}
private boolean registerLoadedExtension(MinePanelExtension extension, String source) {
if (extension == null || extension.id() == null || extension.id().isBlank()) {
plugin.getLogger().warning("Skipping extension with invalid id from " + source);
return false;
}
String id = extension.id().trim().toLowerCase(Locale.ROOT);
if (!loadedIds.add(id)) {
plugin.getLogger().warning("Skipping duplicate extension id: " + extension.id());
return false;
}
try {
extension.onLoad(context);
loadedExtensions.add(extension);
extensionSourceById.put(id, source);
plugin.getLogger().info("Loaded extension " + extension.id() + " from " + source);
return true;
} catch (Exception exception) {
loadedIds.remove(id);
plugin.getLogger().warning("Could not initialize extension " + extension.id() + ": " + exception.getMessage());
return false;
}
}
public record ReloadResult(int scannedArtifactCount, List<String> loadedExtensionIds, List<String> warnings) {
}
private List<String> scanArtifactFileNames() {
if (extensionsDirectory == null) {
return List.of();
}
try (Stream<Path> files = Files.list(extensionsDirectory)) {
return files
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
.map(path -> path.getFileName().toString())
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
} catch (IOException exception) {
plugin.getLogger().warning("Could not read extension artifacts: " + exception.getMessage());
return List.of();
}
}
private int compareJarsNewestFirst(Path left, Path right) {
long leftModified = lastModifiedMillis(left);
long rightModified = lastModifiedMillis(right);
int byModified = Long.compare(rightModified, leftModified);
if (byModified != 0) {
return byModified;
}
String leftName = left.getFileName().toString().toLowerCase(Locale.ROOT);
String rightName = right.getFileName().toString().toLowerCase(Locale.ROOT);
return rightName.compareTo(leftName);
}
private long lastModifiedMillis(Path file) {
try {
return Files.getLastModifiedTime(file).toMillis();
} catch (IOException ignored) {
return 0L;
}
}
private void cleanupDuplicateArtifactsOnStartup(Path extensionDirectory) {
List<Path> jars;
try (Stream<Path> files = Files.list(extensionDirectory)) {
jars = files
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
.toList();
} catch (IOException exception) {
plugin.getLogger().warning("Could not inspect extension artifacts for startup cleanup: " + exception.getMessage());
return;
}
if (jars.size() < 2) {
return;
}
Map<String, Path> keepByKey = new HashMap<>();
Map<String, ArtifactVersionToken> keepVersionByKey = new HashMap<>();
for (Path jar : jars) {
String fileName = jar.getFileName().toString();
String key = extensionKeyFromArtifact(fileName);
if (key.isBlank()) {
continue;
}
ArtifactVersionToken currentVersion = parseArtifactVersion(fileName);
Path keptPath = keepByKey.get(key);
ArtifactVersionToken keptVersion = keepVersionByKey.get(key);
if (keptPath == null || keptVersion == null) {
keepByKey.put(key, jar);
keepVersionByKey.put(key, currentVersion);
continue;
}
int compare = compareArtifactVersions(currentVersion, keptVersion);
if (compare > 0 || (compare == 0 && compareJarsNewestFirst(jar, keptPath) < 0)) {
keepByKey.put(key, jar);
keepVersionByKey.put(key, currentVersion);
}
}
for (Path jar : jars) {
String fileName = jar.getFileName().toString();
String key = extensionKeyFromArtifact(fileName);
if (key.isBlank()) {
continue;
}
Path kept = keepByKey.get(key);
if (kept == null || kept.equals(jar)) {
continue;
}
try {
Files.deleteIfExists(jar);
plugin.getLogger().info("Removed old extension artifact on startup: " + fileName + " (kept " + kept.getFileName() + ")");
} catch (IOException exception) {
plugin.getLogger().warning("Could not delete old extension artifact " + fileName + " on startup: " + exception.getMessage());
}
}
}
private String extensionKeyFromArtifact(String fileName) {
if (fileName == null || fileName.isBlank()) {
return "";
}
String normalized = fileName.trim().toLowerCase(Locale.ROOT);
if (!normalized.endsWith(".jar") || !normalized.startsWith("minepanel-extension-")) {
return "";
}
normalized = normalized.substring(0, normalized.length() - 4);
normalized = normalized.substring("minepanel-extension-".length());
int alphaSplit = normalized.lastIndexOf("-alpha-");
if (alphaSplit > 0) {
normalized = normalized.substring(0, alphaSplit);
} else {
int semverSplit = normalized.lastIndexOf('-');
if (semverSplit > 0) {
String suffix = normalized.substring(semverSplit + 1);
if (isNumericVersionSuffix(suffix)) {
normalized = normalized.substring(0, semverSplit);
}
}
}
return normalized.replaceAll("[^a-z0-9]", "");
}
private String normalizeExtensionId(String rawId) {
if (rawId == null || rawId.isBlank()) {
return "";
}
return rawId.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
}
private ArtifactVersionToken parseArtifactVersion(String fileName) {
if (fileName == null || fileName.isBlank()) {
return ArtifactVersionToken.unknown();
}
String normalized = fileName.trim().toLowerCase(Locale.ROOT);
if (normalized.endsWith(".jar")) {
normalized = normalized.substring(0, normalized.length() - 4);
}
int alphaSplit = normalized.lastIndexOf("-alpha-");
if (alphaSplit > 0) {
String buildRaw = normalized.substring(alphaSplit + "-alpha-".length());
try {
return ArtifactVersionToken.alpha(Integer.parseInt(buildRaw));
} catch (NumberFormatException ignored) {
return ArtifactVersionToken.unknown();
}
}
int semverSplit = normalized.lastIndexOf('-');
if (semverSplit > 0) {
String suffix = normalized.substring(semverSplit + 1);
if (isNumericVersionSuffix(suffix)) {
List<Integer> parts = new ArrayList<>();
for (String part : suffix.split("\\.")) {
if (part.isBlank()) {
continue;
}
try {
parts.add(Integer.parseInt(part));
} catch (NumberFormatException ignored) {
return ArtifactVersionToken.unknown();
}
}
if (!parts.isEmpty()) {
return ArtifactVersionToken.semver(parts);
}
}
}
return ArtifactVersionToken.unknown();
}
private boolean isNumericVersionSuffix(String value) {
if (value == null || value.isBlank()) {
return false;
}
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (!Character.isDigit(c) && c != '.') {
return false;
}
}
return true;
}
private int compareArtifactVersions(ArtifactVersionToken left, ArtifactVersionToken right) {
if (left.kind() != right.kind()) {
return Integer.compare(left.kind().priority, right.kind().priority);
}
if (left.kind() == ArtifactVersionKind.ALPHA) {
return Integer.compare(left.alphaBuild(), right.alphaBuild());
}
if (left.kind() == ArtifactVersionKind.SEMVER) {
int max = Math.max(left.semverParts().size(), right.semverParts().size());
for (int i = 0; i < max; i++) {
int l = i < left.semverParts().size() ? left.semverParts().get(i) : 0;
int r = i < right.semverParts().size() ? right.semverParts().get(i) : 0;
if (l != r) {
return Integer.compare(l, r);
}
}
}
return 0;
}
private enum ArtifactVersionKind {
UNKNOWN(0),
ALPHA(1),
SEMVER(2);
private final int priority;
ArtifactVersionKind(int priority) {
this.priority = priority;
}
}
private record ArtifactVersionToken(ArtifactVersionKind kind, int alphaBuild, List<Integer> semverParts) {
private static ArtifactVersionToken unknown() {
return new ArtifactVersionToken(ArtifactVersionKind.UNKNOWN, -1, List.of());
}
private static ArtifactVersionToken alpha(int build) {
return new ArtifactVersionToken(ArtifactVersionKind.ALPHA, build, List.of());
}
private static ArtifactVersionToken semver(List<Integer> parts) {
return new ArtifactVersionToken(ArtifactVersionKind.SEMVER, -1, parts == null ? List.of() : List.copyOf(parts));
}
}
}
@@ -0,0 +1,9 @@
package de.winniepat.minePanel.extensions;
public record ExtensionNavigationTab(
String category,
String label,
String path
) {
}
@@ -0,0 +1,11 @@
package de.winniepat.minePanel.extensions;
import de.winniepat.minePanel.users.PanelUser;
import spark.Request;
import spark.Response;
@FunctionalInterface
public interface ExtensionRouteHandler {
Object handle(Request request, Response response, PanelUser user) throws Exception;
}
@@ -0,0 +1,16 @@
package de.winniepat.minePanel.extensions;
import de.winniepat.minePanel.users.PanelPermission;
import spark.Response;
import java.util.Map;
public interface ExtensionWebRegistry {
void get(String path, PanelPermission permission, ExtensionRouteHandler handler);
void post(String path, PanelPermission permission, ExtensionRouteHandler handler);
String json(Response response, int status, Map<String, Object> payload);
}
@@ -0,0 +1,88 @@
package de.winniepat.minePanel.extensions;
import java.util.List;
/**
* Service Provider Interface (SPI) for MinePanel extensions.
* <p>
* Implementations are discovered through Java {@code ServiceLoader} and loaded by the
* extension manager during MinePanel startup.
* </p>
* <p>
* Typical lifecycle:
* </p>
* <ol>
* <li>{@link #onLoad(ExtensionContext)}</li>
* <li>{@link #onEnable()}</li>
* <li>{@link #registerWebRoutes(ExtensionWebRegistry)}</li>
* <li>{@link #navigationTabs()}</li>
* <li>{@link #onDisable()} on shutdown</li>
* </ol>
*/
public interface MinePanelExtension {
/**
* Returns the stable, unique id of this extension.
* <p>
* The id is used for runtime bookkeeping and should remain unchanged across versions.
* </p>
*
* @return extension id (for example {@code "reports"})
*/
String id();
/**
* Returns the human-readable display name shown in UI/status views.
*
* @return extension display name
*/
String displayName();
/**
* Called once when the extension is loaded and before it is enabled.
* <p>
* Use this for initialization that requires MinePanel services, such as setting up
* database schema or caching dependencies from the provided context.
* </p>
*
* @param context runtime context provided by MinePanel
*/
default void onLoad(ExtensionContext context) {
}
/**
* Called after {@link #onLoad(ExtensionContext)} when the extension should become active.
* <p>
* Use this for registering listeners, commands, or background tasks.
* </p>
*/
default void onEnable() {
}
/**
* Called when MinePanel is disabling the extension.
* <p>
* Use this hook to release resources and stop tasks started in {@link #onEnable()}.
* </p>
*/
default void onDisable() {
}
/**
* Allows the extension to register HTTP routes in the MinePanel web server.
*
* @param webRegistry route registration facade
*/
default void registerWebRoutes(ExtensionWebRegistry webRegistry) {
}
/**
* Returns sidebar navigation tabs contributed by this extension.
*
* @return list of extension tabs, or an empty list if none are contributed
*/
default List<ExtensionNavigationTab> navigationTabs() {
return List.of();
}
}
@@ -0,0 +1,233 @@
package de.winniepat.minePanel.extensions.airstrike;
import com.google.gson.Gson;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.persistence.KnownPlayer;
import de.winniepat.minePanel.users.PanelPermission;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.entity.TNTPrimed;
import org.bukkit.util.Vector;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class AirstrikeExtension implements MinePanelExtension {
private static final int DEFAULT_TNT = 5000;
private static final int MAX_TNT = 20000;
private static final int WAVE_COUNT = 20;
private static final long WAVE_INTERVAL_TICKS = 12L;
private static final double DROP_HEIGHT = 52.0;
private static final double HORIZONTAL_SPREAD = 5.0;
private final Gson gson = new Gson();
private ExtensionContext context;
@Override
public String id() {
return "airstrike";
}
@Override
public String displayName() {
return "Airstrike";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.post("/api/extensions/airstrike/launch", PanelPermission.MANAGE_AIRSTRIKE, (request, response, user) -> {
AirstrikePayload payload = gson.fromJson(request.body(), AirstrikePayload.class);
if (payload == null || (isBlank(payload.uuid()) && isBlank(payload.username()))) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
int amount = sanitizeAmount(payload.amount());
TargetSnapshot target;
try {
target = context.schedulerBridge().callGlobal(
() -> resolveOnlineTarget(payload.uuid(), payload.username()),
2,
TimeUnit.SECONDS
);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return webRegistry.json(response, 500, Map.of("error", "interrupted"));
} catch (ExecutionException | TimeoutException exception) {
return webRegistry.json(response, 500, Map.of("error", "lookup_failed"));
}
if (target == null) {
return webRegistry.json(response, 404, Map.of("error", "player_not_online"));
}
try {
launchAirstrike(target.uuid(), target.username(), user.username(), amount);
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "airstrike_schedule_failed"));
}
return webRegistry.json(response, 200, Map.of(
"ok", true,
"targetUuid", target.uuid().toString(),
"targetUsername", target.username(),
"tntCount", amount
));
});
}
private void launchAirstrike(UUID targetUuid, String targetName, String actorName, int totalTnt) {
context.panelLogger().log("AUDIT", actorName, "Triggered airstrike on " + targetName + " with " + totalTnt + " TNT");
final de.winniepat.minePanel.util.ServerSchedulerBridge.CancellableTask[] repeatingTask = new de.winniepat.minePanel.util.ServerSchedulerBridge.CancellableTask[1];
Runnable waveTask = new Runnable() {
private int remainingTnt = totalTnt;
private int remainingWaves = WAVE_COUNT;
@Override
public void run() {
Player onlineTarget = context.plugin().getServer().getPlayer(targetUuid);
if (onlineTarget == null || !onlineTarget.isOnline()) {
context.panelLogger().log("SYSTEM", "airstrike", "Airstrike cancelled because target went offline: " + targetName);
if (repeatingTask[0] != null) {
repeatingTask[0].cancel();
}
return;
}
int tntThisWave = (int) Math.ceil((double) remainingTnt / Math.max(1, remainingWaves));
boolean dispatched = runOnPlayerScheduler(onlineTarget, () -> {
for (int index = 0; index < tntThisWave; index++) {
spawnStrikeTnt(onlineTarget);
}
});
if (!dispatched) {
context.panelLogger().log("SYSTEM", "airstrike", "Airstrike cancelled because player scheduler dispatch failed: " + targetName);
if (repeatingTask[0] != null) {
repeatingTask[0].cancel();
}
return;
}
remainingTnt = Math.max(0, remainingTnt - tntThisWave);
remainingWaves--;
if (remainingWaves <= 0 || remainingTnt <= 0) {
if (repeatingTask[0] != null) {
repeatingTask[0].cancel();
}
}
}
};
repeatingTask[0] = context.schedulerBridge().runRepeatingGlobal(waveTask, 0L, WAVE_INTERVAL_TICKS);
}
private boolean runOnPlayerScheduler(Player player, Runnable task) {
if (!context.schedulerBridge().isFolia()) {
task.run();
return true;
}
try {
Object entityScheduler = player.getClass().getMethod("getScheduler").invoke(player);
Method runMethod = entityScheduler.getClass().getMethod("run", org.bukkit.plugin.Plugin.class, java.util.function.Consumer.class, Runnable.class);
runMethod.invoke(entityScheduler, context.plugin(), (java.util.function.Consumer<Object>) ignored -> task.run(), null);
return true;
} catch (ReflectiveOperationException exception) {
return false;
}
}
private void spawnStrikeTnt(Player target) {
ThreadLocalRandom random = ThreadLocalRandom.current();
Location targetLocation = target.getLocation();
Vector velocity = target.getVelocity();
// Lead the strike slightly so waves keep up with sprinting players.
double leadFactor = 6.0;
double predictedX = targetLocation.getX() + (velocity.getX() * leadFactor);
double predictedZ = targetLocation.getZ() + (velocity.getZ() * leadFactor);
double angle = random.nextDouble(0.0, Math.PI * 2.0);
double radius = random.nextDouble(0.0, HORIZONTAL_SPREAD);
double offsetX = Math.cos(angle) * radius;
double offsetZ = Math.sin(angle) * radius;
Location strikeCenter = new Location(targetLocation.getWorld(), predictedX, targetLocation.getY(), predictedZ);
Location spawnLocation = strikeCenter.add(offsetX, DROP_HEIGHT + random.nextDouble(0.0, 8.0), offsetZ);
TNTPrimed tnt = targetLocation.getWorld().spawn(spawnLocation, TNTPrimed.class);
tnt.setFuseTicks(65 + random.nextInt(20));
Vector tntVelocity = new Vector(
random.nextDouble(-0.08, 0.08),
-1.85 - random.nextDouble(0.0, 0.45),
random.nextDouble(-0.08, 0.08)
);
tnt.setVelocity(tntVelocity);
}
private TargetSnapshot resolveOnlineTarget(String rawUuid, String rawUsername) {
if (!isBlank(rawUuid)) {
try {
Player online = context.plugin().getServer().getPlayer(UUID.fromString(rawUuid.trim()));
if (online != null) {
return new TargetSnapshot(online.getUniqueId(), online.getName());
}
} catch (IllegalArgumentException ignored) {
// Try username fallback.
}
}
if (!isBlank(rawUsername)) {
Player online = context.plugin().getServer().getPlayerExact(rawUsername.trim());
if (online != null) {
return new TargetSnapshot(online.getUniqueId(), online.getName());
}
Optional<KnownPlayer> known = context.knownPlayerRepository().findByUsername(rawUsername.trim());
if (known.isPresent()) {
Player knownOnline = context.plugin().getServer().getPlayer(known.get().uuid());
if (knownOnline != null) {
return new TargetSnapshot(knownOnline.getUniqueId(), knownOnline.getName());
}
}
}
return null;
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private int sanitizeAmount(Integer requestedAmount) {
if (requestedAmount == null) {
return DEFAULT_TNT;
}
return Math.max(1, Math.min(MAX_TNT, requestedAmount));
}
private record AirstrikePayload(String uuid, String username, Integer amount) {
}
private record TargetSnapshot(UUID uuid, String username) {
}
}
@@ -0,0 +1,12 @@
package de.winniepat.minePanel.extensions.announcements;
public record Announcement(
long id,
String message,
boolean enabled,
int sortOrder,
long createdAt,
long updatedAt
) {
}
@@ -0,0 +1,160 @@
package de.winniepat.minePanel.extensions.announcements;
import de.winniepat.minePanel.persistence.Database;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public final class AnnouncementRepository {
private final Database database;
public AnnouncementRepository(Database database) {
this.database = database;
}
public void initializeSchema() {
try (Connection connection = database.getConnection();
Statement statement = connection.createStatement()) {
statement.execute("CREATE TABLE IF NOT EXISTS ext_announcements ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "message TEXT NOT NULL,"
+ "enabled INTEGER NOT NULL DEFAULT 1,"
+ "sort_order INTEGER NOT NULL DEFAULT 0,"
+ "created_at INTEGER NOT NULL,"
+ "updated_at INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS ext_announcements_config ("
+ "id INTEGER PRIMARY KEY,"
+ "enabled INTEGER NOT NULL DEFAULT 0,"
+ "interval_seconds INTEGER NOT NULL DEFAULT 300,"
+ "updated_at INTEGER NOT NULL DEFAULT 0"
+ ")");
statement.execute("INSERT OR IGNORE INTO ext_announcements_config(id, enabled, interval_seconds, updated_at) VALUES (1, 0, 300, 0)");
} catch (SQLException exception) {
throw new IllegalStateException("Could not initialize announcements schema", exception);
}
}
public AnnouncementConfig readConfig() {
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT enabled, interval_seconds, updated_at FROM ext_announcements_config WHERE id = 1");
ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next()) {
return new AnnouncementConfig(false, 300, 0L);
}
return new AnnouncementConfig(
resultSet.getInt("enabled") == 1,
Math.max(10, resultSet.getInt("interval_seconds")),
resultSet.getLong("updated_at")
);
} catch (SQLException exception) {
throw new IllegalStateException("Could not read announcements config", exception);
}
}
public void saveConfig(boolean enabled, int intervalSeconds, long updatedAt) {
int normalizedInterval = Math.max(10, Math.min(86_400, intervalSeconds));
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(
"UPDATE ext_announcements_config SET enabled = ?, interval_seconds = ?, updated_at = ? WHERE id = 1"
)) {
statement.setInt(1, enabled ? 1 : 0);
statement.setInt(2, normalizedInterval);
statement.setLong(3, Math.max(0L, updatedAt));
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not save announcements config", exception);
}
}
public List<Announcement> listMessages() {
List<Announcement> messages = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(
"SELECT id, message, enabled, sort_order, created_at, updated_at FROM ext_announcements ORDER BY sort_order ASC, id ASC"
);
ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
messages.add(new Announcement(
resultSet.getLong("id"),
resultSet.getString("message"),
resultSet.getInt("enabled") == 1,
resultSet.getInt("sort_order"),
resultSet.getLong("created_at"),
resultSet.getLong("updated_at")
));
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not list announcements", exception);
}
return messages;
}
public long createMessage(String message, long now) {
int nextSortOrder = nextSortOrder();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO ext_announcements(message, enabled, sort_order, created_at, updated_at) VALUES (?, 1, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
)) {
statement.setString(1, message);
statement.setInt(2, nextSortOrder);
statement.setLong(3, now);
statement.setLong(4, now);
statement.executeUpdate();
try (ResultSet resultSet = statement.getGeneratedKeys()) {
if (resultSet.next()) {
return resultSet.getLong(1);
}
}
return -1L;
} catch (SQLException exception) {
throw new IllegalStateException("Could not create announcement", exception);
}
}
public boolean deleteMessage(long id) {
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement("DELETE FROM ext_announcements WHERE id = ?")) {
statement.setLong(1, id);
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not delete announcement", exception);
}
}
public boolean setMessageEnabled(long id, boolean enabled, long updatedAt) {
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement("UPDATE ext_announcements SET enabled = ?, updated_at = ? WHERE id = ?")) {
statement.setInt(1, enabled ? 1 : 0);
statement.setLong(2, updatedAt);
statement.setLong(3, id);
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not update announcement", exception);
}
}
private int nextSortOrder() {
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT COALESCE(MAX(sort_order), 0) + 1 AS next_order FROM ext_announcements");
ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return resultSet.getInt("next_order");
}
return 1;
} catch (SQLException exception) {
throw new IllegalStateException("Could not compute announcement sort order", exception);
}
}
public record AnnouncementConfig(boolean enabled, int intervalSeconds, long updatedAt) {
}
}
@@ -0,0 +1,188 @@
package de.winniepat.minePanel.extensions.announcements;
import de.winniepat.minePanel.extensions.ExtensionContext;
import org.bukkit.entity.Player;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
public final class AnnouncementService {
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ExtensionContext context;
private final AnnouncementRepository repository;
private volatile boolean enabled;
private volatile int intervalSeconds;
private volatile long nextRunAt;
private int rotationCursor;
private de.winniepat.minePanel.util.ServerSchedulerBridge.CancellableTask task;
public AnnouncementService(ExtensionContext context, AnnouncementRepository repository) {
this.context = context;
this.repository = repository;
AnnouncementRepository.AnnouncementConfig config = repository.readConfig();
this.enabled = config.enabled();
this.intervalSeconds = Math.max(10, config.intervalSeconds());
this.nextRunAt = System.currentTimeMillis() + (long) this.intervalSeconds * 1000L;
this.rotationCursor = 0;
}
public void start() {
stop();
this.task = context.schedulerBridge().runRepeatingGlobal(this::tick, 20L, 20L);
}
public void stop() {
if (task != null) {
task.cancel();
task = null;
}
}
public AnnouncementState state() {
return new AnnouncementState(enabled, intervalSeconds, nextRunAt, repository.listMessages());
}
public void updateConfig(boolean enabled, int intervalSeconds) {
this.enabled = enabled;
this.intervalSeconds = Math.max(10, Math.min(86_400, intervalSeconds));
this.nextRunAt = System.currentTimeMillis() + (long) this.intervalSeconds * 1000L;
repository.saveConfig(this.enabled, this.intervalSeconds, System.currentTimeMillis());
}
public boolean sendNow() {
Announcement next = nextAnnouncement();
if (next == null) {
return false;
}
broadcast(next.message());
nextRunAt = System.currentTimeMillis() + (long) intervalSeconds * 1000L;
return true;
}
public long addMessage(String message) {
long id = repository.createMessage(message, System.currentTimeMillis());
if (id > 0) {
nextRunAt = System.currentTimeMillis() + (long) intervalSeconds * 1000L;
}
return id;
}
public boolean deleteMessage(long id) {
boolean deleted = repository.deleteMessage(id);
if (deleted) {
rotationCursor = 0;
}
return deleted;
}
public boolean setMessageEnabled(long id, boolean enabled) {
boolean updated = repository.setMessageEnabled(id, enabled, System.currentTimeMillis());
if (updated) {
rotationCursor = 0;
}
return updated;
}
private void tick() {
if (!enabled) {
return;
}
long now = System.currentTimeMillis();
if (now < nextRunAt) {
return;
}
Announcement next = nextAnnouncement();
if (next == null) {
nextRunAt = now + (long) intervalSeconds * 1000L;
return;
}
broadcast(next.message());
nextRunAt = now + (long) intervalSeconds * 1000L;
}
private Announcement nextAnnouncement() {
List<Announcement> enabledMessages = repository.listMessages().stream()
.filter(Announcement::enabled)
.toList();
if (enabledMessages.isEmpty()) {
rotationCursor = 0;
return null;
}
if (rotationCursor >= enabledMessages.size()) {
rotationCursor = 0;
}
Announcement selected = enabledMessages.get(rotationCursor);
rotationCursor = (rotationCursor + 1) % enabledMessages.size();
return selected;
}
private void broadcast(String message) {
long nowMillis = System.currentTimeMillis();
List<Player> onlinePlayers = List.copyOf(context.plugin().getServer().getOnlinePlayers());
for (Player onlinePlayer : onlinePlayers) {
String resolved = resolvePlaceholders(message, onlinePlayer, nowMillis, onlinePlayers.size());
onlinePlayer.sendMessage(resolved);
}
context.panelLogger().log("SYSTEM", "ANNOUNCEMENTS", "Broadcasted announcement: " + message);
}
private String resolvePlaceholders(String template, Player player, long nowMillis, int onlineCount) {
if (template == null || template.isBlank()) {
return "";
}
ZoneId zoneId = ZoneId.systemDefault();
var now = Instant.ofEpochMilli(nowMillis).atZone(zoneId);
double tps = readPrimaryTps();
String resolved = template;
resolved = resolved.replace("%player%", player == null ? "Player" : player.getName());
resolved = resolved.replace("%time%", TIME_FORMAT.format(now));
resolved = resolved.replace("%date%", DATE_FORMAT.format(now));
resolved = resolved.replace("%datetime%", DATETIME_FORMAT.format(now));
resolved = resolved.replace("%online%", String.valueOf(onlineCount));
resolved = resolved.replace("%max_players%", String.valueOf(context.plugin().getServer().getMaxPlayers()));
resolved = resolved.replace("%world%", player == null || player.getWorld() == null ? "unknown" : player.getWorld().getName());
resolved = resolved.replace("%server%", context.plugin().getServer().getName());
resolved = resolved.replace("%tps%", tps < 0 ? "N/A" : String.format(Locale.US, "%.2f", tps));
return resolved;
}
private double readPrimaryTps() {
try {
Object result = context.plugin().getServer().getClass().getMethod("getTPS").invoke(context.plugin().getServer());
if (result instanceof double[] values && values.length > 0) {
return values[0];
}
} catch (Exception ignored) {
// Keep placeholder available on implementations without Paper TPS API.
}
return -1.0;
}
public record AnnouncementState(
boolean enabled,
int intervalSeconds,
long nextRunAt,
List<Announcement> messages
) {
}
}
@@ -0,0 +1,173 @@
package de.winniepat.minePanel.extensions.announcements;
import com.google.gson.Gson;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.users.PanelPermission;
import java.util.List;
import java.util.Map;
public final class AnnouncementsExtension implements MinePanelExtension {
private final Gson gson = new Gson();
private ExtensionContext context;
private AnnouncementService service;
@Override
public String id() {
return "announcements";
}
@Override
public String displayName() {
return "Announcements";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
AnnouncementRepository repository = new AnnouncementRepository(context.database());
repository.initializeSchema();
this.service = new AnnouncementService(context, repository);
}
@Override
public void onEnable() {
service.start();
}
@Override
public void onDisable() {
if (service != null) {
service.stop();
}
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/announcements", PanelPermission.VIEW_ANNOUNCEMENTS, (request, response, user) -> {
AnnouncementService.AnnouncementState state = service.state();
List<Map<String, Object>> messages = state.messages().stream()
.map(message -> Map.<String, Object>of(
"id", message.id(),
"message", message.message(),
"enabled", message.enabled(),
"sortOrder", message.sortOrder(),
"createdAt", message.createdAt(),
"updatedAt", message.updatedAt()
))
.toList();
return webRegistry.json(response, 200, Map.of(
"enabled", state.enabled(),
"intervalSeconds", state.intervalSeconds(),
"nextRunAt", state.nextRunAt(),
"messages", messages
));
});
webRegistry.post("/api/extensions/announcements/config", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
ConfigPayload payload = gson.fromJson(request.body(), ConfigPayload.class);
if (payload == null || payload.enabled() == null || payload.intervalSeconds() == null) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
service.updateConfig(payload.enabled(), payload.intervalSeconds());
context.panelLogger().log("AUDIT", user.username(), "Updated announcements configuration");
return webRegistry.json(response, 200, Map.of("ok", true));
});
webRegistry.post("/api/extensions/announcements/messages", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
MessagePayload payload = gson.fromJson(request.body(), MessagePayload.class);
String message = payload == null ? "" : sanitizeMessage(payload.message());
if (message.isBlank()) {
return webRegistry.json(response, 400, Map.of("error", "invalid_message"));
}
long id = service.addMessage(message);
if (id <= 0) {
return webRegistry.json(response, 500, Map.of("error", "create_failed"));
}
context.panelLogger().log("AUDIT", user.username(), "Added announcement #" + id);
return webRegistry.json(response, 201, Map.of("ok", true, "id", id));
});
webRegistry.post("/api/extensions/announcements/messages/:id/delete", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
long id = parseId(request.params("id"));
if (id <= 0) {
return webRegistry.json(response, 400, Map.of("error", "invalid_message_id"));
}
if (!service.deleteMessage(id)) {
return webRegistry.json(response, 404, Map.of("error", "message_not_found"));
}
context.panelLogger().log("AUDIT", user.username(), "Deleted announcement #" + id);
return webRegistry.json(response, 200, Map.of("ok", true));
});
webRegistry.post("/api/extensions/announcements/messages/:id/toggle", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
long id = parseId(request.params("id"));
TogglePayload payload = gson.fromJson(request.body(), TogglePayload.class);
if (id <= 0 || payload == null || payload.enabled() == null) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
if (!service.setMessageEnabled(id, payload.enabled())) {
return webRegistry.json(response, 404, Map.of("error", "message_not_found"));
}
context.panelLogger().log("AUDIT", user.username(), "Set announcement #" + id + " enabled=" + payload.enabled());
return webRegistry.json(response, 200, Map.of("ok", true));
});
webRegistry.post("/api/extensions/announcements/send-now", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
if (!service.sendNow()) {
return webRegistry.json(response, 400, Map.of("error", "no_enabled_messages"));
}
context.panelLogger().log("AUDIT", user.username(), "Sent announcement manually");
return webRegistry.json(response, 200, Map.of("ok", true));
});
}
@Override
public List<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Announcements", "/dashboard/announcements"));
}
private long parseId(String raw) {
try {
return Long.parseLong(raw);
} catch (Exception ignored) {
return -1L;
}
}
private String sanitizeMessage(String raw) {
if (raw == null) {
return "";
}
String trimmed = raw.trim();
if (trimmed.length() > 220) {
return trimmed.substring(0, 220);
}
return trimmed;
}
private record ConfigPayload(Boolean enabled, Integer intervalSeconds) {
}
private record MessagePayload(String message) {
}
private record TogglePayload(Boolean enabled) {
}
}
@@ -0,0 +1,108 @@
package de.winniepat.minePanel.extensions.luckperms;
import de.winniepat.minePanel.extensions.*;
import de.winniepat.minePanel.users.PanelPermission;
import net.luckperms.api.LuckPerms;
import net.luckperms.api.LuckPermsProvider;
import net.luckperms.api.cacheddata.CachedMetaData;
import net.luckperms.api.model.group.Group;
import net.luckperms.api.model.user.User;
import net.luckperms.api.query.QueryOptions;
import java.util.*;
import java.util.concurrent.TimeUnit;
public final class LuckPermsExtension implements MinePanelExtension {
private ExtensionContext context;
@Override
public String id() {
return "luckperms";
}
@Override
public String displayName() {
return "LuckPerms Integration";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/luckperms/player/:uuid", PanelPermission.VIEW_LUCKPERMS, (request, response, user) -> {
UUID playerUuid;
try {
playerUuid = UUID.fromString(request.params("uuid"));
} catch (IllegalArgumentException exception) {
return webRegistry.json(response, 400, Map.of("error", "invalid_uuid"));
}
if (!isLuckPermsAvailable()) {
return webRegistry.json(response, 200, Map.of(
"available", false,
"error", "luckperms_not_present"
));
}
try {
LuckPerms api = LuckPermsProvider.get();
User lpUser = api.getUserManager().loadUser(playerUuid).get(3, TimeUnit.SECONDS);
QueryOptions queryOptions = api.getContextManager()
.getQueryOptions(lpUser)
.orElse(api.getContextManager().getStaticQueryOptions());
CachedMetaData metaData = lpUser.getCachedData().getMetaData(queryOptions);
String prefix = sanitize(metaData.getPrefix());
String suffix = sanitize(metaData.getSuffix());
List<String> groups = lpUser.getInheritedGroups(queryOptions).stream()
.map(Group::getName)
.distinct()
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
List<String> permissions = lpUser.getCachedData().getPermissionData(queryOptions).getPermissionMap().entrySet().stream()
.filter(entry -> Boolean.TRUE.equals(entry.getValue()))
.map(Map.Entry::getKey)
.distinct()
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
List<String> limitedPermissions = permissions.stream().limit(200).toList();
boolean permissionsTruncated = permissions.size() > limitedPermissions.size();
Map<String, Object> payload = new HashMap<>();
payload.put("available", true);
payload.put("primaryGroup", sanitize(lpUser.getPrimaryGroup()));
payload.put("prefix", prefix);
payload.put("suffix", suffix);
payload.put("groups", groups);
payload.put("permissions", limitedPermissions);
payload.put("permissionsCount", permissions.size());
payload.put("permissionsTruncated", permissionsTruncated);
payload.put("generatedAt", System.currentTimeMillis());
return webRegistry.json(response, 200, payload);
} catch (Exception exception) {
return webRegistry.json(response, 500, Map.of(
"available", false,
"error", "luckperms_lookup_failed",
"details", exception.getClass().getSimpleName()
));
}
});
}
private boolean isLuckPermsAvailable() {
return context.plugin().getServer().getPluginManager().getPlugin("LuckPerms") != null;
}
private String sanitize(String value) {
return value == null || value.isBlank() ? "-" : value;
}
}
@@ -0,0 +1,141 @@
package de.winniepat.minePanel.extensions.maintenance;
import com.google.gson.Gson;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.users.PanelPermission;
import org.bukkit.event.HandlerList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class MaintenanceExtension implements MinePanelExtension {
private final Gson gson = new Gson();
private ExtensionContext context;
private MaintenanceService maintenanceService;
private MaintenanceJoinListener joinListener;
private MaintenanceMotdListener motdListener;
@Override
public String id() {
return "maintenance";
}
@Override
public String displayName() {
return "Maintenance";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
this.maintenanceService = new MaintenanceService(context);
}
@Override
public void onEnable() {
this.joinListener = new MaintenanceJoinListener(maintenanceService);
this.motdListener = new MaintenanceMotdListener(maintenanceService);
context.plugin().getServer().getPluginManager().registerEvents(joinListener, context.plugin());
context.plugin().getServer().getPluginManager().registerEvents(motdListener, context.plugin());
}
@Override
public void onDisable() {
if (joinListener != null) {
HandlerList.unregisterAll(joinListener);
joinListener = null;
}
if (motdListener != null) {
HandlerList.unregisterAll(motdListener);
motdListener = null;
}
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/maintenance/status", PanelPermission.VIEW_MAINTENANCE, (request, response, user) -> {
MaintenanceService.MaintenanceSnapshot snapshot = maintenanceService.snapshot();
return webRegistry.json(response, 200, Map.of(
"enabled", snapshot.enabled(),
"reason", snapshot.reason(),
"motd", snapshot.motd(),
"changedBy", snapshot.changedBy(),
"changedAt", snapshot.changedAt(),
"affectedOnlinePlayers", snapshot.affectedOnlinePlayers(),
"bypassPermission", MaintenanceService.BYPASS_PERMISSION
));
});
webRegistry.post("/api/extensions/maintenance/enable", PanelPermission.MANAGE_MAINTENANCE, (request, response, user) -> {
TogglePayload payload = gson.fromJson(request.body(), TogglePayload.class);
String reason = payload != null ? payload.reason() : null;
String motd = payload != null ? payload.motd() : null;
boolean kickNonStaff = payload != null && Boolean.TRUE.equals(payload.kickNonStaff());
int kicked;
try {
kicked = maintenanceService.enable(user.username(), reason, motd, kickNonStaff);
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "maintenance_enable_failed", "details", exception.getMessage()));
}
context.panelLogger().log(
"AUDIT",
user.username(),
"Enabled maintenance mode" + (kickNonStaff ? " and kicked " + kicked + " player(s)" : "")
);
return webRegistry.json(response, 200, toStatusPayload(true, kicked));
});
webRegistry.post("/api/extensions/maintenance/disable", PanelPermission.MANAGE_MAINTENANCE, (request, response, user) -> {
maintenanceService.disable(user.username());
context.panelLogger().log("AUDIT", user.username(), "Disabled maintenance mode");
return webRegistry.json(response, 200, toStatusPayload(true, 0));
});
webRegistry.post("/api/extensions/maintenance/kick-nonstaff", PanelPermission.MANAGE_MAINTENANCE, (request, response, user) -> {
if (!maintenanceService.isEnabled()) {
return webRegistry.json(response, 400, Map.of("error", "maintenance_not_enabled"));
}
int kicked;
try {
kicked = maintenanceService.kickNonStaffPlayers();
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "maintenance_kick_failed", "details", exception.getMessage()));
}
context.panelLogger().log("AUDIT", user.username(), "Kicked " + kicked + " non-staff player(s) during maintenance");
return webRegistry.json(response, 200, toStatusPayload(true, kicked));
});
}
@Override
public List<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Maintenance", "/dashboard/maintenance"));
}
private Map<String, Object> toStatusPayload(boolean ok, int kicked) {
MaintenanceService.MaintenanceSnapshot snapshot = maintenanceService.snapshot();
Map<String, Object> payload = new HashMap<>();
payload.put("ok", ok);
payload.put("enabled", snapshot.enabled());
payload.put("reason", snapshot.reason());
payload.put("motd", snapshot.motd());
payload.put("changedBy", snapshot.changedBy());
payload.put("changedAt", snapshot.changedAt());
payload.put("affectedOnlinePlayers", snapshot.affectedOnlinePlayers());
payload.put("kicked", kicked);
payload.put("bypassPermission", MaintenanceService.BYPASS_PERMISSION);
return payload;
}
private record TogglePayload(String reason, String motd, Boolean kickNonStaff) {
}
}
@@ -0,0 +1,29 @@
package de.winniepat.minePanel.extensions.maintenance;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerLoginEvent;
public final class MaintenanceJoinListener implements Listener {
private final MaintenanceService maintenanceService;
public MaintenanceJoinListener(MaintenanceService maintenanceService) {
this.maintenanceService = maintenanceService;
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onPlayerLogin(PlayerLoginEvent event) {
if (!maintenanceService.isEnabled()) {
return;
}
if (maintenanceService.canBypass(event.getPlayer())) {
return;
}
event.disallow(PlayerLoginEvent.Result.KICK_OTHER, maintenanceService.kickMessage());
}
}
@@ -0,0 +1,25 @@
package de.winniepat.minePanel.extensions.maintenance;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.server.ServerListPingEvent;
public final class MaintenanceMotdListener implements Listener {
private final MaintenanceService maintenanceService;
public MaintenanceMotdListener(MaintenanceService maintenanceService) {
this.maintenanceService = maintenanceService;
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onServerListPing(ServerListPingEvent event) {
if (!maintenanceService.isEnabled()) {
return;
}
event.setMotd(maintenanceService.motd());
}
}
@@ -0,0 +1,157 @@
package de.winniepat.minePanel.extensions.maintenance;
import de.winniepat.minePanel.extensions.ExtensionContext;
import org.bukkit.entity.Player;
import java.time.Instant;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class MaintenanceService {
public static final String BYPASS_PERMISSION = "minepanel.maintenance.bypass";
private final ExtensionContext context;
private volatile boolean enabled;
private volatile String reason = "§cServer maintenance in progress";
private volatile String motd = "§cMaintenance mode is active";
private volatile String changedBy = "SYSTEM";
private volatile long changedAt = Instant.now().toEpochMilli();
public MaintenanceService(ExtensionContext context) {
this.context = context;
}
public MaintenanceSnapshot snapshot() {
return new MaintenanceSnapshot(enabled, reason, motd, changedBy, changedAt, countAffectedOnlinePlayers());
}
public int enable(String actor, String nextReason, String nextMotd, boolean kickNonStaff) {
enabled = true;
reason = normalizeReason(nextReason);
motd = normalizeMotd(nextMotd);
changedBy = normalizeActor(actor);
changedAt = Instant.now().toEpochMilli();
if (!kickNonStaff) {
return 0;
}
return kickNonStaffPlayers();
}
public void disable(String actor) {
enabled = false;
changedBy = normalizeActor(actor);
changedAt = Instant.now().toEpochMilli();
}
public boolean isEnabled() {
return enabled;
}
public String reason() {
return reason;
}
public String changedBy() {
return changedBy;
}
public String motd() {
return motd;
}
public long changedAt() {
return changedAt;
}
public boolean canBypass(Player player) {
return player != null && (player.isOp() || player.hasPermission(BYPASS_PERMISSION));
}
public int kickNonStaffPlayers() {
return runSync(this::kickNonStaffPlayersSync);
}
private int kickNonStaffPlayersSync() {
int kicked = 0;
for (Player online : context.plugin().getServer().getOnlinePlayers()) {
if (canBypass(online)) {
continue;
}
kicked++;
online.kickPlayer(kickMessage());
}
return kicked;
}
public int countAffectedOnlinePlayers() {
return runSync(this::countAffectedOnlinePlayersSync);
}
private int countAffectedOnlinePlayersSync() {
int affected = 0;
for (Player online : context.plugin().getServer().getOnlinePlayers()) {
if (!canBypass(online)) {
affected++;
}
}
return affected;
}
public String kickMessage() {
return "Maintenance mode is active. " + reason;
}
private String normalizeReason(String value) {
if (value == null || value.isBlank()) {
return "Server maintenance in progress";
}
String trimmed = value.trim();
return trimmed.length() > 180 ? trimmed.substring(0, 180) : trimmed;
}
private String normalizeMotd(String value) {
if (value == null || value.isBlank()) {
return "Maintenance mode is active";
}
String trimmed = value.trim();
return trimmed.length() > 120 ? trimmed.substring(0, 120) : trimmed;
}
private String normalizeActor(String value) {
if (value == null || value.isBlank()) {
return "SYSTEM";
}
return value.trim();
}
private int runSync(SyncIntTask task) {
try {
return context.schedulerBridge().callGlobal(task::run, 10, TimeUnit.SECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("maintenance_task_interrupted", exception);
} catch (ExecutionException | TimeoutException exception) {
throw new IllegalStateException("maintenance_task_failed", exception);
}
}
@FunctionalInterface
private interface SyncIntTask {
int run();
}
public record MaintenanceSnapshot(
boolean enabled,
String reason,
String motd,
String changedBy,
long changedAt,
int affectedOnlinePlayers
) {
}
}
@@ -0,0 +1,308 @@
package de.winniepat.minePanel.extensions.playermanagement;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import de.winniepat.minePanel.extensions.ExtensionConfigurable;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.persistence.ExtensionSettingsRepository;
import de.winniepat.minePanel.persistence.KnownPlayer;
import de.winniepat.minePanel.users.PanelPermission;
import org.bukkit.entity.Player;
import org.bukkit.event.HandlerList;
import java.time.Instant;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public final class PlayerManagementExtension implements MinePanelExtension, ExtensionConfigurable {
private final Gson gson = new Gson();
private ExtensionContext context;
private PlayerMuteRepository muteRepository;
private PlayerMuteListener muteListener;
private ExtensionSettingsRepository extensionSettingsRepository;
@Override
public String id() {
return "player-management";
}
@Override
public String displayName() {
return "Player Management";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
this.muteRepository = new PlayerMuteRepository(context.database());
this.muteRepository.initializeSchema();
this.extensionSettingsRepository = new ExtensionSettingsRepository(context.database());
}
@Override
public void onEnable() {
muteRepository.clearExpired(Instant.now().toEpochMilli());
ChatFilterSettings settings = loadChatFilterSettings();
muteListener = new PlayerMuteListener(
muteRepository,
context.panelLogger(),
settings.enabled(),
settings.badWords(),
settings.autoMuteMinutes(),
settings.autoMuteReason(),
settings.cancelMessage()
);
context.plugin().getServer().getPluginManager().registerEvents(muteListener, context.plugin());
}
@Override
public void onDisable() {
if (muteListener != null) {
HandlerList.unregisterAll(muteListener);
muteListener = null;
}
}
@Override
public void onSettingsUpdated(String settingsJson) {
ChatFilterSettings latest = parseChatFilterSettings(settingsJson);
if (muteListener == null) {
return;
}
muteListener.updateFilterConfig(
latest.enabled(),
latest.badWords(),
latest.autoMuteMinutes(),
latest.autoMuteReason(),
latest.cancelMessage()
);
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.post("/api/extensions/player-management/mute", PanelPermission.MANAGE_PLAYER_MANAGEMENT, (request, response, user) -> {
MutePayload payload = gson.fromJson(request.body(), MutePayload.class);
if (payload == null || (isBlank(payload.uuid()) && isBlank(payload.username()))) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
Optional<KnownPlayer> target = resolveTarget(payload.uuid(), payload.username());
if (target.isEmpty()) {
return webRegistry.json(response, 404, Map.of("error", "player_not_found"));
}
int minutes = payload.durationMinutes() == null ? 0 : Math.max(0, Math.min(43_200, payload.durationMinutes()));
String reason = isBlank(payload.reason()) ? "Muted by panel moderator" : payload.reason().trim();
long now = Instant.now().toEpochMilli();
Long expiresAt = minutes <= 0 ? null : now + minutes * 60_000L;
muteRepository.upsertMute(target.get().uuid(), target.get().username(), reason, user.username(), now, expiresAt);
context.panelLogger().log("AUDIT", user.username(), "Muted " + target.get().username() + (minutes > 0 ? " for " + minutes + " minute(s)" : " permanently") + ": " + reason);
Player online = context.plugin().getServer().getPlayer(target.get().uuid());
if (online != null) {
online.sendMessage("You were muted by a moderator. Reason: " + reason);
}
java.util.Map<String, Object> resultPayload = new java.util.HashMap<>();
resultPayload.put("ok", true);
resultPayload.put("uuid", target.get().uuid().toString());
resultPayload.put("username", target.get().username());
resultPayload.put("expiresAt", expiresAt);
return webRegistry.json(response, 200, resultPayload);
});
webRegistry.post("/api/extensions/player-management/unmute", PanelPermission.MANAGE_PLAYER_MANAGEMENT, (request, response, user) -> {
UnmutePayload payload = gson.fromJson(request.body(), UnmutePayload.class);
if (payload == null || (isBlank(payload.uuid()) && isBlank(payload.username()))) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
Optional<KnownPlayer> target = resolveTarget(payload.uuid(), payload.username());
if (target.isEmpty()) {
return webRegistry.json(response, 404, Map.of("error", "player_not_found"));
}
boolean removed = muteRepository.removeMute(target.get().uuid());
if (!removed) {
return webRegistry.json(response, 404, Map.of("error", "player_not_muted"));
}
context.panelLogger().log("AUDIT", user.username(), "Unmuted " + target.get().username());
return webRegistry.json(response, 200, Map.of("ok", true));
});
webRegistry.get("/api/extensions/player-management/mute/:uuid", PanelPermission.VIEW_PLAYER_MANAGEMENT, (request, response, user) -> {
UUID uuid;
try {
uuid = UUID.fromString(request.params("uuid"));
} catch (IllegalArgumentException exception) {
return webRegistry.json(response, 400, Map.of("error", "invalid_uuid"));
}
muteRepository.clearExpired(Instant.now().toEpochMilli());
Optional<PlayerMute> mute = muteRepository.findByUuid(uuid);
if (mute.isEmpty()) {
return webRegistry.json(response, 200, Map.of("muted", false));
}
return webRegistry.json(response, 200, Map.of(
"muted", true,
"username", mute.get().username(),
"reason", mute.get().reason(),
"mutedBy", mute.get().mutedBy(),
"mutedAt", mute.get().mutedAt(),
"expiresAt", mute.get().expiresAt()
));
});
}
private Optional<KnownPlayer> resolveTarget(String rawUuid, String rawUsername) {
if (!isBlank(rawUuid)) {
try {
UUID uuid = UUID.fromString(rawUuid.trim());
Optional<KnownPlayer> known = context.knownPlayerRepository().findByUuid(uuid);
if (known.isPresent()) {
return known;
}
} catch (IllegalArgumentException ignored) {
// Fallback to username lookup.
}
}
if (!isBlank(rawUsername)) {
Optional<KnownPlayer> knownByName = context.knownPlayerRepository().findByUsername(rawUsername.trim());
if (knownByName.isPresent()) {
return knownByName;
}
Player online = context.plugin().getServer().getPlayerExact(rawUsername.trim());
if (online != null) {
long now = Instant.now().toEpochMilli();
context.knownPlayerRepository().upsert(online.getUniqueId(), online.getName(), now);
return context.knownPlayerRepository().findByUuid(online.getUniqueId());
}
}
return Optional.empty();
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private Set<String> parseBadWords(List<String> configuredWords) {
Set<String> result = new LinkedHashSet<>();
if (configuredWords == null) {
return result;
}
for (String rawWord : configuredWords) {
if (rawWord == null || rawWord.isBlank()) {
continue;
}
result.add(rawWord.trim());
}
return result;
}
private ChatFilterSettings loadChatFilterSettings() {
String settingsJson = extensionSettingsRepository.findSettingsJson(id()).orElse("{}");
return parseChatFilterSettings(settingsJson);
}
private ChatFilterSettings parseChatFilterSettings(String settingsJson) {
try {
JsonElement root = gson.fromJson(settingsJson, JsonElement.class);
if (root == null || !root.isJsonObject()) {
return ChatFilterSettings.defaults();
}
JsonObject rootObject = root.getAsJsonObject();
JsonObject filter = rootObject.has("badWordFilter") && rootObject.get("badWordFilter").isJsonObject()
? rootObject.getAsJsonObject("badWordFilter")
: new JsonObject();
boolean enabled = booleanValue(filter, "enabled", false);
int autoMuteMinutes = intValue(filter, "autoMuteMinutes", 15, 0, 43_200);
String autoMuteReason = stringValue(filter, "autoMuteReason", "Inappropriate language");
boolean cancelMessage = booleanValue(filter, "cancelMessage", true);
Set<String> words = parseBadWords(readWords(filter));
return new ChatFilterSettings(enabled, words, autoMuteMinutes, autoMuteReason, cancelMessage);
} catch (Exception exception) {
context.plugin().getLogger().warning("Could not parse player-management extension settings: " + exception.getMessage());
return ChatFilterSettings.defaults();
}
}
private List<String> readWords(JsonObject filter) {
if (filter == null || !filter.has("words") || !filter.get("words").isJsonArray()) {
return List.of();
}
JsonArray array = filter.getAsJsonArray("words");
java.util.ArrayList<String> words = new java.util.ArrayList<>();
for (JsonElement element : array) {
if (element == null || !element.isJsonPrimitive()) {
continue;
}
words.add(element.getAsString());
}
return words;
}
private boolean booleanValue(JsonObject object, String key, boolean fallback) {
if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()) {
return fallback;
}
try {
return object.get(key).getAsBoolean();
} catch (Exception ignored) {
return fallback;
}
}
private int intValue(JsonObject object, String key, int fallback, int min, int max) {
if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()) {
return fallback;
}
try {
int value = object.get(key).getAsInt();
return Math.max(min, Math.min(max, value));
} catch (Exception ignored) {
return fallback;
}
}
private String stringValue(JsonObject object, String key, String fallback) {
if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()) {
return fallback;
}
String value = object.get(key).getAsString();
return (value == null || value.isBlank()) ? fallback : value.trim();
}
private record ChatFilterSettings(boolean enabled, Set<String> badWords, int autoMuteMinutes, String autoMuteReason, boolean cancelMessage) {
private static ChatFilterSettings defaults() {
return new ChatFilterSettings(false, Set.of(), 15, "Inappropriate language", true);
}
}
private record MutePayload(String uuid, String username, Integer durationMinutes, String reason) {
}
private record UnmutePayload(String uuid, String username) {
}
}
@@ -0,0 +1,21 @@
package de.winniepat.minePanel.extensions.playermanagement;
import java.util.UUID;
public record PlayerMute(
UUID uuid,
String username,
String reason,
String mutedBy,
long mutedAt,
Long expiresAt
) {
public boolean isExpired(long nowMillis) {
return expiresAt != null && expiresAt > 0 && nowMillis >= expiresAt;
}
public boolean isActive(long nowMillis) {
return !isExpired(nowMillis);
}
}
@@ -0,0 +1,164 @@
package de.winniepat.minePanel.extensions.playermanagement;
import de.winniepat.minePanel.logs.PanelLogger;
import io.papermc.paper.event.player.AsyncChatEvent;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.AsyncPlayerChatEvent;
import java.time.Instant;
import java.util.HashSet;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
public final class PlayerMuteListener implements Listener {
private static final PlainTextComponentSerializer PLAIN_TEXT_SERIALIZER = PlainTextComponentSerializer.plainText();
private final PlayerMuteRepository muteRepository;
private final PanelLogger panelLogger;
private volatile boolean badWordFilterEnabled;
private volatile Set<String> badWords;
private volatile int autoMuteMinutes;
private volatile String autoMuteReason;
private volatile boolean cancelBlockedMessage;
public PlayerMuteListener(
PlayerMuteRepository muteRepository,
PanelLogger panelLogger,
boolean badWordFilterEnabled,
Set<String> badWords,
int autoMuteMinutes,
String autoMuteReason,
boolean cancelBlockedMessage
) {
this.muteRepository = muteRepository;
this.panelLogger = panelLogger;
updateFilterConfig(badWordFilterEnabled, badWords, autoMuteMinutes, autoMuteReason, cancelBlockedMessage);
}
public void updateFilterConfig(
boolean badWordFilterEnabled,
Set<String> badWords,
int autoMuteMinutes,
String autoMuteReason,
boolean cancelBlockedMessage
) {
this.badWordFilterEnabled = badWordFilterEnabled;
this.badWords = badWords == null ? Set.of() : new HashSet<>(badWords);
this.autoMuteMinutes = Math.max(0, autoMuteMinutes);
this.autoMuteReason = (autoMuteReason == null || autoMuteReason.isBlank())
? "Inappropriate language"
: autoMuteReason.trim();
this.cancelBlockedMessage = cancelBlockedMessage;
}
@EventHandler(ignoreCancelled = true)
public void onAsyncPlayerChat(AsyncPlayerChatEvent event) {
processChatMessage(event.getPlayer(), event.getMessage(), event::setCancelled);
}
@EventHandler(ignoreCancelled = true)
public void onAsyncChat(AsyncChatEvent event) {
processChatMessage(event.getPlayer(), PLAIN_TEXT_SERIALIZER.serialize(event.message()), event::setCancelled);
}
private void processChatMessage(Player player, String message, CancelHandler cancelHandler) {
long now = Instant.now().toEpochMilli();
Optional<PlayerMute> mute = muteRepository.findByUuid(player.getUniqueId());
if (mute.isEmpty()) {
handleBadWordFilter(player, message, now, cancelHandler);
return;
}
PlayerMute activeMute = mute.get();
if (activeMute.isExpired(now)) {
muteRepository.removeMute(activeMute.uuid());
handleBadWordFilter(player, message, now, cancelHandler);
return;
}
cancelHandler.cancel(true);
if (activeMute.expiresAt() == null) {
player.sendMessage(ChatColor.RED + "You are muted. Reason: " + activeMute.reason());
return;
}
long secondsLeft = Math.max(1L, (activeMute.expiresAt() - now) / 1000L);
player.sendMessage(ChatColor.RED + "You are muted for another " + secondsLeft + "s. Reason: " + activeMute.reason());
}
private void handleBadWordFilter(Player player, String message, long now, CancelHandler cancelHandler) {
if (!badWordFilterEnabled || badWords.isEmpty()) {
return;
}
if (player.hasPermission("minepanel.chatfilter.bypass") || player.isOp()) {
return;
}
String matchedWord = findMatchedBadWord(message);
if (matchedWord == null) {
return;
}
Long expiresAt = autoMuteMinutes <= 0 ? null : now + autoMuteMinutes * 60_000L;
muteRepository.upsertMute(
player.getUniqueId(),
player.getName(),
autoMuteReason,
"AUTO_MOD",
now,
expiresAt
);
if (cancelBlockedMessage) {
cancelHandler.cancel(true);
}
panelLogger.log(
"SECURITY",
"CHAT_FILTER",
"Auto-muted " + player.getName() + " for bad language (matched: " + matchedWord + ")"
);
if (expiresAt == null) {
player.sendMessage(ChatColor.RED + "Your message contained blocked language. You have been muted. Reason: " + autoMuteReason);
return;
}
player.sendMessage(ChatColor.RED + "Your message contained blocked language. You have been muted for " + autoMuteMinutes + " minute(s). Reason: " + autoMuteReason);
}
private String findMatchedBadWord(String message) {
if (message == null || message.isBlank()) {
return null;
}
String lower = message.toLowerCase(Locale.ROOT);
for (String badWord : badWords) {
if (badWord == null || badWord.isBlank()) {
continue;
}
String normalizedBadWord = badWord.toLowerCase(Locale.ROOT).trim();
String pattern = "(^|[^a-z0-9])" + Pattern.quote(normalizedBadWord) + "([^a-z0-9]|$)";
if (Pattern.compile(pattern).matcher(lower).find()) {
return badWord;
}
}
return null;
}
@FunctionalInterface
private interface CancelHandler {
void cancel(boolean cancelled);
}
}
@@ -0,0 +1,112 @@
package de.winniepat.minePanel.extensions.playermanagement;
import de.winniepat.minePanel.persistence.Database;
import java.sql.*;
import java.util.*;
public final class PlayerMuteRepository {
private final Database database;
public PlayerMuteRepository(Database database) {
this.database = database;
}
public void initializeSchema() {
String sql = "CREATE TABLE IF NOT EXISTS player_mutes ("
+ "uuid TEXT PRIMARY KEY,"
+ "username TEXT NOT NULL,"
+ "reason TEXT NOT NULL,"
+ "muted_by TEXT NOT NULL,"
+ "muted_at INTEGER NOT NULL,"
+ "expires_at INTEGER"
+ ")";
try (Connection connection = database.getConnection();
Statement statement = connection.createStatement()) {
statement.execute(sql);
} catch (SQLException exception) {
throw new IllegalStateException("Could not initialize mute schema", exception);
}
}
public void upsertMute(UUID uuid, String username, String reason, String mutedBy, long mutedAt, Long expiresAt) {
String sql = "INSERT INTO player_mutes(uuid, username, reason, muted_by, muted_at, expires_at) VALUES (?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(uuid) DO UPDATE SET "
+ "username = excluded.username, "
+ "reason = excluded.reason, "
+ "muted_by = excluded.muted_by, "
+ "muted_at = excluded.muted_at, "
+ "expires_at = excluded.expires_at";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
statement.setString(2, username == null ? "" : username);
statement.setString(3, reason == null ? "" : reason);
statement.setString(4, mutedBy == null ? "" : mutedBy);
statement.setLong(5, mutedAt);
if (expiresAt == null || expiresAt <= 0) {
statement.setNull(6, Types.BIGINT);
} else {
statement.setLong(6, expiresAt);
}
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not save mute", exception);
}
}
public Optional<PlayerMute> findByUuid(UUID uuid) {
String sql = "SELECT uuid, username, reason, muted_by, muted_at, expires_at FROM player_mutes WHERE uuid = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(read(resultSet));
}
}
return Optional.empty();
} catch (SQLException exception) {
throw new IllegalStateException("Could not read mute", exception);
}
}
public boolean removeMute(UUID uuid) {
String sql = "DELETE FROM player_mutes WHERE uuid = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not delete mute", exception);
}
}
public int clearExpired(long nowMillis) {
String sql = "DELETE FROM player_mutes WHERE expires_at IS NOT NULL AND expires_at > 0 AND expires_at <= ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, nowMillis);
return statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not clear expired mutes", exception);
}
}
private PlayerMute read(ResultSet resultSet) throws SQLException {
long rawExpires = resultSet.getLong("expires_at");
Long expiresAt = resultSet.wasNull() ? null : rawExpires;
return new PlayerMute(
UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username"),
resultSet.getString("reason"),
resultSet.getString("muted_by"),
resultSet.getLong("muted_at"),
expiresAt
);
}
}
@@ -0,0 +1,182 @@
package de.winniepat.minePanel.extensions.playerstats;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.users.PanelPermission;
import org.bukkit.OfflinePlayer;
import org.bukkit.Statistic;
import java.lang.reflect.Method;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class PlayerStatsExtension implements MinePanelExtension {
private ExtensionContext context;
@Override
public String id() {
return "player-stats";
}
@Override
public String displayName() {
return "Player Stats";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/player-stats/player/:uuid", PanelPermission.VIEW_PLAYER_STATS, (request, response, user) -> {
UUID playerUuid;
try {
playerUuid = UUID.fromString(request.params("uuid"));
} catch (IllegalArgumentException exception) {
return webRegistry.json(response, 400, Map.of("error", "invalid_uuid"));
}
StatsSnapshot snapshot;
try {
snapshot = fetchStats(playerUuid);
} catch (Exception exception) {
return webRegistry.json(response, 500, Map.of(
"available", false,
"error", "player_stats_lookup_failed",
"details", exception.getClass().getSimpleName()
));
}
Map<String, Object> payload = new HashMap<>();
payload.put("available", true);
payload.put("kills", snapshot.kills());
payload.put("deaths", snapshot.deaths());
payload.put("economyAvailable", snapshot.economyAvailable());
payload.put("balance", snapshot.balance());
payload.put("balanceFormatted", snapshot.balanceFormatted());
payload.put("economyProvider", snapshot.economyProvider());
payload.put("generatedAt", System.currentTimeMillis());
return webRegistry.json(response, 200, payload);
});
}
private StatsSnapshot fetchStats(UUID playerUuid) throws ExecutionException, InterruptedException, TimeoutException {
return context.schedulerBridge().callGlobal(() -> {
OfflinePlayer offlinePlayer = context.plugin().getServer().getOfflinePlayer(playerUuid);
int kills = safeStatistic(offlinePlayer, Statistic.PLAYER_KILLS);
int deaths = safeStatistic(offlinePlayer, Statistic.DEATHS);
EconomyLookup economy = lookupEconomy(offlinePlayer);
return new StatsSnapshot(kills, deaths, economy.available(), economy.balance(), economy.formatted(), economy.provider());
}, 2, TimeUnit.SECONDS);
}
private int safeStatistic(OfflinePlayer player, Statistic statistic) {
try {
return player.getStatistic(statistic);
} catch (Exception ignored) {
return 0;
}
}
private EconomyLookup lookupEconomy(OfflinePlayer player) {
try {
Class<?> economyClass = Class.forName("net.milkbowl.vault.economy.Economy");
Object registration = context.plugin().getServer().getServicesManager().getRegistration(economyClass);
if (registration == null) {
return EconomyLookup.unavailable();
}
Method getProvider = registration.getClass().getMethod("getProvider");
Object provider = getProvider.invoke(registration);
if (provider == null) {
return EconomyLookup.unavailable();
}
String providerName;
try {
Method getName = provider.getClass().getMethod("getName");
providerName = String.valueOf(getName.invoke(provider));
} catch (Exception ignored) {
providerName = provider.getClass().getSimpleName();
}
Double balance = invokeBalance(provider, player);
if (balance == null) {
return new EconomyLookup(true, null, "-", providerName);
}
String formatted = formatBalance(provider, balance);
return new EconomyLookup(true, balance, formatted, providerName);
} catch (ClassNotFoundException ignored) {
return EconomyLookup.unavailable();
} catch (Exception ignored) {
return EconomyLookup.unavailable();
}
}
private Double invokeBalance(Object provider, OfflinePlayer player) {
try {
Method getBalanceOffline = provider.getClass().getMethod("getBalance", OfflinePlayer.class);
Object value = getBalanceOffline.invoke(provider, player);
if (value instanceof Number number) {
return number.doubleValue();
}
} catch (Exception ignored) {
// Fallback below.
}
try {
Method getBalanceString = provider.getClass().getMethod("getBalance", String.class);
String playerName = player.getName();
if (playerName == null || playerName.isBlank()) {
return null;
}
Object value = getBalanceString.invoke(provider, playerName);
if (value instanceof Number number) {
return number.doubleValue();
}
} catch (Exception ignored) {
// No compatible method available.
}
return null;
}
private String formatBalance(Object provider, double value) {
try {
Method formatMethod = provider.getClass().getMethod("format", double.class);
Object formatted = formatMethod.invoke(provider, value);
if (formatted != null) {
return String.valueOf(formatted);
}
} catch (Exception ignored) {
// Fallback below.
}
return String.format(Locale.US, "%.2f", value);
}
private record StatsSnapshot(
int kills,
int deaths,
boolean economyAvailable,
Double balance,
String balanceFormatted,
String economyProvider
) {
}
private record EconomyLookup(boolean available, Double balance, String formatted, String provider) {
private static EconomyLookup unavailable() {
return new EconomyLookup(false, null, "-", "");
}
}
}
@@ -0,0 +1,18 @@
package de.winniepat.minePanel.extensions.reports;
import java.util.UUID;
public record PlayerReport(
long id,
UUID reporterUuid,
String reporterName,
UUID suspectUuid,
String suspectName,
String reason,
String status,
long createdAt,
String reviewedBy,
long reviewedAt
) {
}
@@ -0,0 +1,73 @@
package de.winniepat.minePanel.extensions.reports;
import de.winniepat.minePanel.logs.PanelLogger;
import de.winniepat.minePanel.persistence.KnownPlayerRepository;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.time.Instant;
import java.util.Arrays;
public final class ReportCommand implements CommandExecutor {
private final ReportRepository reportRepository;
private final KnownPlayerRepository knownPlayerRepository;
private final PanelLogger panelLogger;
public ReportCommand(ReportRepository reportRepository, KnownPlayerRepository knownPlayerRepository, PanelLogger panelLogger) {
this.reportRepository = reportRepository;
this.knownPlayerRepository = knownPlayerRepository;
this.panelLogger = panelLogger;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player reporter)) {
sender.sendMessage(ChatColor.RED + "Only players can create reports.");
return true;
}
if (args.length < 2) {
sender.sendMessage(ChatColor.YELLOW + "Usage: /report <player> <reason>");
return true;
}
Player suspect = reporter.getServer().getPlayerExact(args[0]);
if (suspect == null) {
sender.sendMessage(ChatColor.RED + "The reported player must currently be online.");
return true;
}
if (suspect.getUniqueId().equals(reporter.getUniqueId())) {
sender.sendMessage(ChatColor.RED + "You cannot report yourself.");
return true;
}
String reason = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).trim();
if (reason.isBlank()) {
sender.sendMessage(ChatColor.RED + "Please provide a reason.");
return true;
}
long now = Instant.now().toEpochMilli();
knownPlayerRepository.upsert(reporter.getUniqueId(), reporter.getName(), now);
knownPlayerRepository.upsert(suspect.getUniqueId(), suspect.getName(), now);
long reportId = reportRepository.createReport(
reporter.getUniqueId(),
reporter.getName(),
suspect.getUniqueId(),
suspect.getName(),
reason,
now
);
sender.sendMessage(ChatColor.GREEN + "Report submitted (#" + reportId + ") for " + suspect.getName() + ".");
panelLogger.log("REPORT", reporter.getName(), "Created report #" + reportId + " against " + suspect.getName() + ": " + reason);
return true;
}
}
@@ -0,0 +1,130 @@
package de.winniepat.minePanel.extensions.reports;
import de.winniepat.minePanel.persistence.Database;
import java.sql.*;
import java.util.*;
public final class ReportRepository {
private final Database database;
public ReportRepository(Database database) {
this.database = database;
}
public void initializeSchema() {
String sql = "CREATE TABLE IF NOT EXISTS player_reports ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "reporter_uuid TEXT NOT NULL,"
+ "reporter_name TEXT NOT NULL,"
+ "suspect_uuid TEXT NOT NULL,"
+ "suspect_name TEXT NOT NULL,"
+ "reason TEXT NOT NULL,"
+ "status TEXT NOT NULL,"
+ "created_at INTEGER NOT NULL,"
+ "reviewed_by TEXT NOT NULL DEFAULT '',"
+ "reviewed_at INTEGER NOT NULL DEFAULT 0"
+ ")";
try (Connection connection = database.getConnection();
Statement statement = connection.createStatement()) {
statement.execute(sql);
} catch (SQLException exception) {
throw new IllegalStateException("Could not initialize report schema", exception);
}
}
public long createReport(UUID reporterUuid, String reporterName, UUID suspectUuid, String suspectName, String reason, long createdAt) {
String sql = "INSERT INTO player_reports(reporter_uuid, reporter_name, suspect_uuid, suspect_name, reason, status, created_at, reviewed_by, reviewed_at) "
+ "VALUES (?, ?, ?, ?, ?, 'OPEN', ?, '', 0)";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
statement.setString(1, reporterUuid.toString());
statement.setString(2, reporterName);
statement.setString(3, suspectUuid.toString());
statement.setString(4, suspectName);
statement.setString(5, reason == null ? "" : reason);
statement.setLong(6, createdAt);
statement.executeUpdate();
try (ResultSet generatedKeys = statement.getGeneratedKeys()) {
if (generatedKeys.next()) {
return generatedKeys.getLong(1);
}
}
return 0L;
} catch (SQLException exception) {
throw new IllegalStateException("Could not create player report", exception);
}
}
public List<PlayerReport> listReports(String status) {
boolean filterStatus = status != null && !status.isBlank();
String sql = filterStatus
? "SELECT * FROM player_reports WHERE status = ? ORDER BY created_at DESC, id DESC"
: "SELECT * FROM player_reports ORDER BY created_at DESC, id DESC";
List<PlayerReport> reports = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
if (filterStatus) {
statement.setString(1, status.trim().toUpperCase(Locale.ROOT));
}
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
reports.add(read(resultSet));
}
}
return reports;
} catch (SQLException exception) {
throw new IllegalStateException("Could not list reports", exception);
}
}
public Optional<PlayerReport> findById(long id) {
String sql = "SELECT * FROM player_reports WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, id);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(read(resultSet));
}
}
return Optional.empty();
} catch (SQLException exception) {
throw new IllegalStateException("Could not read report", exception);
}
}
public boolean markResolved(long id, String reviewedBy, long reviewedAt) {
String sql = "UPDATE player_reports SET status = 'RESOLVED', reviewed_by = ?, reviewed_at = ? WHERE id = ? AND status = 'OPEN'";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, reviewedBy == null ? "" : reviewedBy);
statement.setLong(2, reviewedAt);
statement.setLong(3, id);
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not resolve report", exception);
}
}
private PlayerReport read(ResultSet resultSet) throws SQLException {
return new PlayerReport(
resultSet.getLong("id"),
UUID.fromString(resultSet.getString("reporter_uuid")),
resultSet.getString("reporter_name"),
UUID.fromString(resultSet.getString("suspect_uuid")),
resultSet.getString("suspect_name"),
resultSet.getString("reason"),
resultSet.getString("status"),
resultSet.getLong("created_at"),
resultSet.getString("reviewed_by"),
resultSet.getLong("reviewed_at")
);
}
}
@@ -0,0 +1,182 @@
package de.winniepat.minePanel.extensions.reports;
import com.google.gson.Gson;
import de.winniepat.minePanel.extensions.*;
import de.winniepat.minePanel.logs.PanelLogger;
import de.winniepat.minePanel.persistence.KnownPlayerRepository;
import de.winniepat.minePanel.users.PanelPermission;
import org.bukkit.BanList;
import org.bukkit.entity.Player;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class ReportSystemExtension implements MinePanelExtension {
private final Gson gson = new Gson();
private ExtensionContext context;
private ReportRepository reportRepository;
@Override
public String id() {
return "report-system";
}
@Override
public String displayName() {
return "Report System";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
this.reportRepository = new ReportRepository(context.database());
this.reportRepository.initializeSchema();
}
@Override
public void onEnable() {
KnownPlayerRepository knownPlayerRepository = context.knownPlayerRepository();
PanelLogger panelLogger = context.panelLogger();
boolean registered = context.commandRegistry().register(
id(),
"report",
"Report a player to MinePanel moderators",
"/report <player> <reason>",
"minepanel.report",
List.of(),
new ReportCommand(reportRepository, knownPlayerRepository, panelLogger)
);
if (!registered) {
context.plugin().getLogger().warning("Could not register /report command for report extension.");
}
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/reports", PanelPermission.VIEW_REPORTS, (request, response, user) -> {
String status = request.queryParams("status");
List<Map<String, Object>> reports = reportRepository.listReports(status).stream().map(this::toReportPayload).toList();
return webRegistry.json(response, 200, Map.of("reports", reports));
});
webRegistry.post("/api/extensions/reports/:id/resolve", PanelPermission.MANAGE_REPORTS, (request, response, user) -> {
long reportId = parseReportId(request.params("id"));
if (reportId <= 0) {
return webRegistry.json(response, 400, Map.of("error", "invalid_report_id"));
}
boolean updated = reportRepository.markResolved(reportId, user.username(), Instant.now().toEpochMilli());
if (!updated) {
return webRegistry.json(response, 404, Map.of("error", "report_not_found_or_closed"));
}
context.panelLogger().log("AUDIT", user.username(), "Resolved player report #" + reportId);
return webRegistry.json(response, 200, Map.of("ok", true));
});
webRegistry.post("/api/extensions/reports/:id/ban", PanelPermission.MANAGE_REPORTS, (request, response, user) -> {
long reportId = parseReportId(request.params("id"));
if (reportId <= 0) {
return webRegistry.json(response, 400, Map.of("error", "invalid_report_id"));
}
Optional<PlayerReport> report = reportRepository.findById(reportId);
if (report.isEmpty()) {
return webRegistry.json(response, 404, Map.of("error", "report_not_found"));
}
BanPayload payload = gson.fromJson(request.body(), BanPayload.class);
Integer durationMinutes = payload == null ? null : payload.durationMinutes();
String reason = payload == null || isBlank(payload.reason())
? "Banned by report review (#" + reportId + ")"
: payload.reason().trim();
BanResult banResult = banPlayer(report.get(), durationMinutes, reason, user.username());
if (!banResult.success()) {
return webRegistry.json(response, 500, Map.of("error", "ban_failed", "details", banResult.error()));
}
reportRepository.markResolved(reportId, user.username(), Instant.now().toEpochMilli());
context.panelLogger().log("AUDIT", user.username(), "Banned " + report.get().suspectName() + " from report #" + reportId + ": " + reason);
java.util.Map<String, Object> resultPayload = new java.util.HashMap<>();
resultPayload.put("ok", true);
resultPayload.put("username", report.get().suspectName());
resultPayload.put("expiresAt", banResult.expiresAt());
return webRegistry.json(response, 200, resultPayload);
});
}
@Override
public List<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Reports", "/dashboard/reports"));
}
private Map<String, Object> toReportPayload(PlayerReport report) {
return Map.of(
"id", report.id(),
"reporterUuid", report.reporterUuid().toString(),
"reporterName", report.reporterName(),
"suspectUuid", report.suspectUuid().toString(),
"suspectName", report.suspectName(),
"reason", report.reason(),
"status", report.status(),
"createdAt", report.createdAt(),
"reviewedBy", report.reviewedBy(),
"reviewedAt", report.reviewedAt()
);
}
private long parseReportId(String rawId) {
try {
return Long.parseLong(rawId);
} catch (Exception ignored) {
return -1L;
}
}
private BanResult banPlayer(PlayerReport report, Integer durationMinutes, String reason, String actor) {
try {
return context.schedulerBridge().callGlobal(() -> {
Date expiresAt = null;
if (durationMinutes != null && durationMinutes > 0) {
int clampedMinutes = Math.min(durationMinutes, 43_200);
expiresAt = Date.from(Instant.now().plusSeconds(clampedMinutes * 60L));
}
context.plugin().getServer().getBanList(BanList.Type.NAME)
.addBan(report.suspectName(), reason, expiresAt, "MinePanelReport:" + actor);
Player online = context.plugin().getServer().getPlayer(report.suspectUuid());
if (online == null) {
online = context.plugin().getServer().getPlayerExact(report.suspectName());
}
if (online != null) {
online.kickPlayer("Banned. Reason: " + reason);
}
return new BanResult(true, expiresAt == null ? null : expiresAt.getTime(), "");
}, 2, TimeUnit.SECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return new BanResult(false, null, "interrupted");
} catch (ExecutionException | TimeoutException exception) {
return new BanResult(false, null, exception.getClass().getSimpleName());
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private record BanPayload(Integer durationMinutes, String reason) {
}
private record BanResult(boolean success, Long expiresAt, String error) {
}
}
@@ -0,0 +1,18 @@
package de.winniepat.minePanel.extensions.tickets;
import java.util.UUID;
public record PlayerTicket(
long id,
UUID creatorUuid,
String creatorName,
String category,
String description,
String status,
long createdAt,
long updatedAt,
String handledBy,
long handledAt
) {
}
@@ -0,0 +1,28 @@
package de.winniepat.minePanel.extensions.tickets;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public final class TicketCommand implements CommandExecutor {
private final TicketMenuListener menuListener;
public TicketCommand(TicketMenuListener menuListener) {
this.menuListener = menuListener;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage(ChatColor.RED + "Only players can create support tickets.");
return true;
}
menuListener.openMenu(player);
return true;
}
}
@@ -0,0 +1,107 @@
package de.winniepat.minePanel.extensions.tickets;
import de.winniepat.minePanel.logs.PanelLogger;
import de.winniepat.minePanel.persistence.KnownPlayerRepository;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.time.Instant;
import java.util.List;
import java.util.Map;
public final class TicketMenuListener implements Listener {
private static final String MENU_TITLE = ChatColor.DARK_AQUA + "MinePanel Tickets";
private final TicketRepository ticketRepository;
private final KnownPlayerRepository knownPlayerRepository;
private final PanelLogger panelLogger;
public TicketMenuListener(TicketRepository ticketRepository, KnownPlayerRepository knownPlayerRepository, PanelLogger panelLogger) {
this.ticketRepository = ticketRepository;
this.knownPlayerRepository = knownPlayerRepository;
this.panelLogger = panelLogger;
}
public void openMenu(Player player) {
Inventory menu = Bukkit.createInventory(null, 27, MENU_TITLE);
menu.setItem(11, createTicketItem(Material.REDSTONE, ChatColor.RED + "Technical Issue", List.of(ChatColor.GRAY + "Server lag, crashes or bugs")));
menu.setItem(13, createTicketItem(Material.PAPER, ChatColor.AQUA + "Question / Help", List.of(ChatColor.GRAY + "Need support from the team")));
menu.setItem(15, createTicketItem(Material.BOOK, ChatColor.GREEN + "Other", List.of(ChatColor.GRAY + "General support request")));
menu.setItem(22, createTicketItem(Material.BARRIER, ChatColor.RED + "Close", List.of(ChatColor.GRAY + "Close this menu")));
player.openInventory(menu);
}
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) {
return;
}
if (!MENU_TITLE.equals(event.getView().getTitle())) {
return;
}
event.setCancelled(true);
int slot = event.getRawSlot();
if (slot < 0 || slot >= event.getView().getTopInventory().getSize()) {
return;
}
Map<Integer, TicketTemplate> templates = Map.of(
11, new TicketTemplate("Technical Issue", "Reported from in-game menu: technical issue"),
13, new TicketTemplate("Question / Help", "Reported from in-game menu: question or support needed"),
15, new TicketTemplate("Other", "Reported from in-game menu: general support request")
);
if (slot == 22) {
player.closeInventory();
return;
}
TicketTemplate template = templates.get(slot);
if (template == null) {
return;
}
long now = Instant.now().toEpochMilli();
knownPlayerRepository.upsert(player.getUniqueId(), player.getName(), now);
long ticketId = ticketRepository.createTicket(
player.getUniqueId(),
player.getName(),
template.category(),
template.description(),
now
);
player.closeInventory();
player.sendMessage(ChatColor.GREEN + "Ticket submitted (#" + ticketId + ") in category " + template.category() + ".");
panelLogger.log("TICKET", player.getName(), "Created ticket #" + ticketId + " [" + template.category() + "] " + template.description());
}
private ItemStack createTicketItem(Material material, String name, List<String> lore) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(name);
meta.setLore(lore);
item.setItemMeta(meta);
}
return item;
}
private record TicketTemplate(String category, String description) {
}
}
@@ -0,0 +1,120 @@
package de.winniepat.minePanel.extensions.tickets;
import de.winniepat.minePanel.persistence.Database;
import java.sql.*;
import java.util.*;
public final class TicketRepository {
private final Database database;
public TicketRepository(Database database) {
this.database = database;
}
public void initializeSchema() {
String sql = "CREATE TABLE IF NOT EXISTS player_tickets ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "creator_uuid TEXT NOT NULL,"
+ "creator_name TEXT NOT NULL,"
+ "category TEXT NOT NULL,"
+ "description TEXT NOT NULL,"
+ "status TEXT NOT NULL,"
+ "created_at INTEGER NOT NULL,"
+ "updated_at INTEGER NOT NULL,"
+ "handled_by TEXT NOT NULL DEFAULT '',"
+ "handled_at INTEGER NOT NULL DEFAULT 0"
+ ")";
try (Connection connection = database.getConnection();
Statement statement = connection.createStatement()) {
statement.execute(sql);
} catch (SQLException exception) {
throw new IllegalStateException("Could not initialize ticket schema", exception);
}
}
public long createTicket(UUID creatorUuid, String creatorName, String category, String description, long createdAt) {
String sql = "INSERT INTO player_tickets(creator_uuid, creator_name, category, description, status, created_at, updated_at, handled_by, handled_at) "
+ "VALUES (?, ?, ?, ?, 'OPEN', ?, ?, '', 0)";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
statement.setString(1, creatorUuid.toString());
statement.setString(2, creatorName == null ? "Unknown" : creatorName);
statement.setString(3, category == null || category.isBlank() ? "Other" : category.trim());
statement.setString(4, description == null ? "" : description.trim());
statement.setLong(5, createdAt);
statement.setLong(6, createdAt);
statement.executeUpdate();
try (ResultSet generatedKeys = statement.getGeneratedKeys()) {
if (generatedKeys.next()) {
return generatedKeys.getLong(1);
}
}
return 0L;
} catch (SQLException exception) {
throw new IllegalStateException("Could not create ticket", exception);
}
}
public List<PlayerTicket> listTickets(String status) {
boolean filterStatus = status != null && !status.isBlank();
String sql = filterStatus
? "SELECT * FROM player_tickets WHERE status = ? ORDER BY created_at DESC, id DESC"
: "SELECT * FROM player_tickets ORDER BY created_at DESC, id DESC";
List<PlayerTicket> tickets = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
if (filterStatus) {
statement.setString(1, status.trim().toUpperCase(Locale.ROOT));
}
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
tickets.add(read(resultSet));
}
}
return tickets;
} catch (SQLException exception) {
throw new IllegalStateException("Could not list tickets", exception);
}
}
public boolean updateStatus(long id, String status, String handledBy, long handledAt) {
if (id <= 0 || status == null || status.isBlank()) {
return false;
}
String sql = "UPDATE player_tickets SET status = ?, updated_at = ?, handled_by = ?, handled_at = ? WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, status.trim().toUpperCase(Locale.ROOT));
statement.setLong(2, handledAt);
statement.setString(3, handledBy == null ? "" : handledBy);
statement.setLong(4, handledAt);
statement.setLong(5, id);
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not update ticket status", exception);
}
}
private PlayerTicket read(ResultSet resultSet) throws SQLException {
return new PlayerTicket(
resultSet.getLong("id"),
UUID.fromString(resultSet.getString("creator_uuid")),
resultSet.getString("creator_name"),
resultSet.getString("category"),
resultSet.getString("description"),
resultSet.getString("status"),
resultSet.getLong("created_at"),
resultSet.getLong("updated_at"),
resultSet.getString("handled_by"),
resultSet.getLong("handled_at")
);
}
}
@@ -0,0 +1,131 @@
package de.winniepat.minePanel.extensions.tickets;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.users.PanelPermission;
import org.bukkit.event.HandlerList;
import java.time.Instant;
import java.util.*;
public final class TicketSystemExtension implements MinePanelExtension {
private ExtensionContext context;
private TicketRepository ticketRepository;
private TicketMenuListener menuListener;
@Override
public String id() {
return "ticket-system";
}
@Override
public String displayName() {
return "Ticket System";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
this.ticketRepository = new TicketRepository(context.database());
this.ticketRepository.initializeSchema();
}
@Override
public void onEnable() {
this.menuListener = new TicketMenuListener(ticketRepository, context.knownPlayerRepository(), context.panelLogger());
context.plugin().getServer().getPluginManager().registerEvents(menuListener, context.plugin());
boolean registered = context.commandRegistry().register(
id(),
"ticket",
"Create a support ticket for server staff",
"/ticket",
"minepanel.ticket",
List.of("support"),
new TicketCommand(menuListener)
);
if (!registered) {
context.plugin().getLogger().warning("Could not register /ticket command for ticket extension.");
}
}
@Override
public void onDisable() {
if (menuListener != null) {
HandlerList.unregisterAll(menuListener);
menuListener = null;
}
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/tickets", PanelPermission.VIEW_TICKETS, (request, response, user) -> {
String status = request.queryParams("status");
List<Map<String, Object>> tickets = ticketRepository.listTickets(status).stream().map(this::toPayload).toList();
return webRegistry.json(response, 200, Map.of("tickets", tickets));
});
webRegistry.post("/api/extensions/tickets/:id/close", PanelPermission.MANAGE_TICKETS, (request, response, user) -> {
long ticketId = parseTicketId(request.params("id"));
if (ticketId <= 0) {
return webRegistry.json(response, 400, Map.of("error", "invalid_ticket_id"));
}
boolean updated = ticketRepository.updateStatus(ticketId, "CLOSED", user.username(), Instant.now().toEpochMilli());
if (!updated) {
return webRegistry.json(response, 404, Map.of("error", "ticket_not_found"));
}
context.panelLogger().log("AUDIT", user.username(), "Closed ticket #" + ticketId);
return webRegistry.json(response, 200, Map.of("ok", true));
});
webRegistry.post("/api/extensions/tickets/:id/reopen", PanelPermission.MANAGE_TICKETS, (request, response, user) -> {
long ticketId = parseTicketId(request.params("id"));
if (ticketId <= 0) {
return webRegistry.json(response, 400, Map.of("error", "invalid_ticket_id"));
}
boolean updated = ticketRepository.updateStatus(ticketId, "OPEN", user.username(), Instant.now().toEpochMilli());
if (!updated) {
return webRegistry.json(response, 404, Map.of("error", "ticket_not_found"));
}
context.panelLogger().log("AUDIT", user.username(), "Reopened ticket #" + ticketId);
return webRegistry.json(response, 200, Map.of("ok", true));
});
}
@Override
public List<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Tickets", "/dashboard/tickets"));
}
private long parseTicketId(String rawId) {
try {
return Long.parseLong(rawId);
} catch (Exception ignored) {
return -1L;
}
}
private Map<String, Object> toPayload(PlayerTicket ticket) {
return Map.of(
"id", ticket.id(),
"creatorUuid", ticket.creatorUuid().toString(),
"creatorName", ticket.creatorName(),
"category", ticket.category(),
"description", ticket.description(),
"status", ticket.status(),
"createdAt", ticket.createdAt(),
"updatedAt", ticket.updatedAt(),
"handledBy", ticket.handledBy(),
"handledAt", ticket.handledAt()
);
}
}
@@ -0,0 +1,139 @@
package de.winniepat.minePanel.extensions.whitelist;
import com.google.gson.Gson;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.users.PanelPermission;
import java.util.List;
import java.util.Map;
public final class WhitelistExtension implements MinePanelExtension {
private final Gson gson = new Gson();
private ExtensionContext context;
private WhitelistService whitelistService;
@Override
public String id() {
return "whitelist";
}
@Override
public String displayName() {
return "Whitelist";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
this.whitelistService = new WhitelistService(context);
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/whitelist/status", PanelPermission.VIEW_WHITELIST, (request, response, user) -> {
try {
boolean enabled = whitelistService.isWhitelistEnabled();
return webRegistry.json(response, 200, Map.of("enabled", enabled));
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "whitelist_status_failed", "details", exception.getMessage()));
}
});
webRegistry.post("/api/extensions/whitelist/toggle", PanelPermission.MANAGE_WHITELIST, (request, response, user) -> {
TogglePayload payload = gson.fromJson(request.body(), TogglePayload.class);
if (payload == null || payload.enabled() == null) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
boolean changed;
try {
changed = whitelistService.setWhitelistEnabled(payload.enabled());
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "whitelist_toggle_failed", "details", exception.getMessage()));
}
context.panelLogger().log(
"AUDIT",
user.username(),
(payload.enabled() ? "Enabled" : "Disabled") + " server whitelist" + (changed ? "" : " (no change)")
);
return webRegistry.json(response, 200, Map.of("ok", true, "enabled", payload.enabled(), "changed", changed));
});
webRegistry.get("/api/extensions/whitelist", PanelPermission.VIEW_WHITELIST, (request, response, user) -> {
try {
List<Map<String, Object>> entries = whitelistService.listEntries();
return webRegistry.json(response, 200, Map.of("entries", entries, "count", entries.size()));
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "whitelist_list_failed", "details", exception.getMessage()));
}
});
webRegistry.post("/api/extensions/whitelist/add", PanelPermission.MANAGE_WHITELIST, (request, response, user) -> {
UsernamePayload payload = gson.fromJson(request.body(), UsernamePayload.class);
String username = payload == null ? null : payload.username();
WhitelistService.ChangeResult result;
try {
result = whitelistService.addByUsername(username);
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "whitelist_add_failed", "details", exception.getMessage()));
}
if (!result.success()) {
return webRegistry.json(response, 400, Map.of("error", result.error()));
}
context.panelLogger().log("AUDIT", user.username(),
(result.changed() ? "Added " : "Kept ") + result.username() + " on whitelist");
return webRegistry.json(response, 200, Map.of(
"ok", true,
"changed", result.changed(),
"uuid", result.uuid() == null ? "" : result.uuid().toString(),
"username", result.username()
));
});
webRegistry.post("/api/extensions/whitelist/remove", PanelPermission.MANAGE_WHITELIST, (request, response, user) -> {
UsernamePayload payload = gson.fromJson(request.body(), UsernamePayload.class);
String username = payload == null ? null : payload.username();
WhitelistService.ChangeResult result;
try {
result = whitelistService.removeByUsername(username);
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "whitelist_remove_failed", "details", exception.getMessage()));
}
if (!result.success()) {
int status = "not_whitelisted".equals(result.error()) ? 404 : 400;
return webRegistry.json(response, status, Map.of("error", result.error()));
}
context.panelLogger().log("AUDIT", user.username(), "Removed " + result.username() + " from whitelist");
return webRegistry.json(response, 200, Map.of(
"ok", true,
"changed", result.changed(),
"uuid", result.uuid() == null ? "" : result.uuid().toString(),
"username", result.username()
));
});
}
@Override
public List<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Whitelist", "/dashboard/whitelist"));
}
private record UsernamePayload(String username) {
}
private record TogglePayload(Boolean enabled) {
}
}
@@ -0,0 +1,155 @@
package de.winniepat.minePanel.extensions.whitelist;
import de.winniepat.minePanel.extensions.ExtensionContext;
import org.bukkit.OfflinePlayer;
import java.util.*;
import java.util.concurrent.*;
public final class WhitelistService {
private final ExtensionContext context;
public WhitelistService(ExtensionContext context) {
this.context = context;
}
public List<Map<String, Object>> listEntries() {
return runSync(() -> {
List<Map<String, Object>> entries = new ArrayList<>();
for (OfflinePlayer player : context.plugin().getServer().getWhitelistedPlayers()) {
String name = player.getName();
String username = (name == null || name.isBlank()) ? player.getUniqueId().toString() : name;
entries.add(Map.of(
"uuid", player.getUniqueId().toString(),
"username", username,
"online", player.isOnline()
));
}
entries.sort(Comparator.comparing(entry -> String.valueOf(entry.get("username")), String.CASE_INSENSITIVE_ORDER));
return entries;
});
}
public boolean isWhitelistEnabled() {
return runSync(() -> context.plugin().getServer().hasWhitelist());
}
public boolean setWhitelistEnabled(boolean enabled) {
return runSync(() -> {
boolean before = context.plugin().getServer().hasWhitelist();
if (before == enabled) {
return false;
}
context.plugin().getServer().setWhitelist(enabled);
return true;
});
}
public ChangeResult addByUsername(String rawUsername) {
String username = normalizeUsername(rawUsername);
if (username.isBlank()) {
return ChangeResult.invalid("invalid_username");
}
return runSync(() -> {
OfflinePlayer target = resolvePlayerByUsername(username);
if (target == null) {
return ChangeResult.invalid("player_not_found");
}
if (target.isWhitelisted()) {
return ChangeResult.ok(target, false);
}
target.setWhitelisted(true);
return ChangeResult.ok(target, true);
});
}
public ChangeResult removeByUsername(String rawUsername) {
String username = normalizeUsername(rawUsername);
if (username.isBlank()) {
return ChangeResult.invalid("invalid_username");
}
return runSync(() -> {
OfflinePlayer target = findWhitelistedByUsername(username);
if (target == null) {
return ChangeResult.invalid("not_whitelisted");
}
target.setWhitelisted(false);
if (target.isOnline() && target.getPlayer() != null) {
target.getPlayer().kickPlayer("You have been removed from the whitelist.");
}
return ChangeResult.ok(target, true);
});
}
private OfflinePlayer resolvePlayerByUsername(String username) {
OfflinePlayer online = context.plugin().getServer().getPlayerExact(username);
if (online != null) {
return online;
}
for (OfflinePlayer offline : context.plugin().getServer().getOfflinePlayers()) {
if (offline.getName() != null && offline.getName().equalsIgnoreCase(username)) {
return offline;
}
}
OfflinePlayer fallback = context.plugin().getServer().getOfflinePlayer(username);
return (fallback.getName() == null && !fallback.hasPlayedBefore()) ? null : fallback;
}
private OfflinePlayer findWhitelistedByUsername(String username) {
for (OfflinePlayer player : context.plugin().getServer().getWhitelistedPlayers()) {
String name = player.getName();
if (name != null && name.equalsIgnoreCase(username)) {
return player;
}
}
return null;
}
private String normalizeUsername(String value) {
if (value == null) {
return "";
}
String username = value.trim();
if (username.length() < 3 || username.length() > 16) {
return "";
}
if (!username.matches("^[A-Za-z0-9_]+$")) {
return "";
}
return username;
}
private <T> T runSync(Callable<T> task) {
try {
return context.schedulerBridge().callGlobal(task, 10, TimeUnit.SECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("whitelist_task_interrupted", exception);
} catch (ExecutionException | TimeoutException exception) {
throw new IllegalStateException("whitelist_task_failed", exception);
}
}
public record ChangeResult(boolean success, boolean changed, String error, UUID uuid, String username) {
private static ChangeResult ok(OfflinePlayer player, boolean changed) {
String username = player.getName() == null || player.getName().isBlank()
? player.getUniqueId().toString()
: player.getName();
return new ChangeResult(true, changed, "", player.getUniqueId(), username);
}
private static ChangeResult invalid(String error) {
return new ChangeResult(false, false, error, null, "");
}
}
}
@@ -0,0 +1,389 @@
package de.winniepat.minePanel.extensions.worldbackups;
import de.winniepat.minePanel.MinePanel;
import org.bukkit.World;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
public final class WorldBackupService {
private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss").withZone(ZoneOffset.UTC);
private static final Set<String> WORLD_KEYS = Set.of("overworld", "nether", "end");
private final MinePanel plugin;
private final Path backupsRoot;
public WorldBackupService(MinePanel plugin, Path backupsRoot) {
this.plugin = plugin;
this.backupsRoot = backupsRoot;
}
public List<String> supportedWorldKeys() {
return List.of("overworld", "nether", "end");
}
public BackupCreateResult createBackup(String worldKey, String requestedName) {
String normalizedWorldKey = normalizeWorldKey(worldKey);
if (normalizedWorldKey == null) {
return BackupCreateResult.error("invalid_world");
}
String safeName = sanitizeBackupName(requestedName);
if (safeName.isBlank()) {
return BackupCreateResult.error("invalid_name");
}
WorldSnapshot snapshot = captureWorldSnapshot(normalizedWorldKey);
if (snapshot == null) {
return BackupCreateResult.error("world_not_found");
}
Path worldBackupDir = backupsRoot.resolve(normalizedWorldKey);
String fileName = FILE_TIMESTAMP.format(Instant.now()) + "__" + safeName + ".zip";
Path tempZip = null;
try {
Files.createDirectories(backupsRoot);
Files.createDirectories(worldBackupDir);
Path zipFile = resolveUniqueZipPath(worldBackupDir, fileName);
tempZip = resolveUniqueTempPath(worldBackupDir);
zipDirectory(snapshot.worldFolder(), tempZip);
try {
Files.move(tempZip, zipFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException ignored) {
Files.move(tempZip, zipFile, StandardCopyOption.REPLACE_EXISTING);
}
long sizeBytes = Files.size(zipFile);
long createdAt = Files.getLastModifiedTime(zipFile).toMillis();
return BackupCreateResult.success(new WorldBackupEntry(zipFile.getFileName().toString(), safeName, createdAt, sizeBytes, normalizedWorldKey));
} catch (Exception exception) {
return BackupCreateResult.error("backup_failed", buildErrorDetails(exception));
} finally {
if (tempZip != null) {
try {
Files.deleteIfExists(tempZip);
} catch (IOException ignored) {
// Best effort cleanup.
}
}
}
}
private Path resolveUniqueZipPath(Path worldBackupDir, String baseFileName) throws IOException {
Path initial = worldBackupDir.resolve(baseFileName).normalize();
if (!Files.exists(initial)) {
return initial;
}
String base = baseFileName;
String suffix = ".zip";
if (baseFileName.toLowerCase(Locale.ROOT).endsWith(suffix)) {
base = baseFileName.substring(0, baseFileName.length() - suffix.length());
}
for (int i = 2; i <= 9999; i++) {
Path candidate = worldBackupDir.resolve(base + "-" + i + suffix).normalize();
if (!Files.exists(candidate)) {
return candidate;
}
}
throw new IOException("could_not_allocate_unique_backup_file_name");
}
private Path resolveUniqueTempPath(Path worldBackupDir) throws IOException {
for (int i = 0; i < 10_000; i++) {
String candidateName = "backup-" + UUID.randomUUID() + ".tmp";
Path candidate = worldBackupDir.resolve(candidateName).normalize();
if (!Files.exists(candidate)) {
return candidate;
}
}
throw new IOException("could_not_allocate_unique_temp_file_name");
}
private String buildErrorDetails(Exception exception) {
String message = exception.getMessage();
if (message == null || message.isBlank()) {
return exception.getClass().getSimpleName();
}
return exception.getClass().getSimpleName() + ": " + message;
}
public List<WorldBackupEntry> listBackups(String worldKey) {
String normalizedWorldKey = normalizeWorldKey(worldKey);
if (normalizedWorldKey == null) {
return List.of();
}
Path worldBackupDir = backupsRoot.resolve(normalizedWorldKey);
if (!Files.isDirectory(worldBackupDir)) {
return List.of();
}
try (var files = Files.list(worldBackupDir)) {
return files
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip"))
.map(path -> toEntry(path, normalizedWorldKey))
.filter(Objects::nonNull)
.sorted(Comparator.comparingLong(WorldBackupEntry::createdAt).reversed())
.toList();
} catch (IOException exception) {
return List.of();
}
}
public BackupDeleteResult deleteBackup(String worldKey, String fileName) {
String normalizedWorldKey = normalizeWorldKey(worldKey);
if (normalizedWorldKey == null) {
return BackupDeleteResult.error("invalid_world");
}
if (fileName == null || fileName.isBlank()) {
return BackupDeleteResult.error("invalid_file_name");
}
String trimmedFileName = fileName.trim();
if (!trimmedFileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
return BackupDeleteResult.error("invalid_file_name");
}
if (trimmedFileName.contains("/") || trimmedFileName.contains("\\") || trimmedFileName.contains("..")) {
return BackupDeleteResult.error("invalid_file_name");
}
Path worldBackupDir = backupsRoot.resolve(normalizedWorldKey).normalize();
Path target = worldBackupDir.resolve(trimmedFileName).normalize();
if (!target.startsWith(worldBackupDir)) {
return BackupDeleteResult.error("invalid_file_name");
}
if (!Files.exists(target) || !Files.isRegularFile(target)) {
return BackupDeleteResult.error("backup_not_found");
}
try {
Files.delete(target);
return BackupDeleteResult.success(trimmedFileName, normalizedWorldKey);
} catch (Exception exception) {
return BackupDeleteResult.error("delete_failed", buildErrorDetails(exception));
}
}
private WorldSnapshot captureWorldSnapshot(String worldKey) {
try {
return plugin.schedulerBridge().callGlobal(() -> {
World world = resolveWorld(worldKey);
if (world == null) {
return null;
}
world.save();
return new WorldSnapshot(world.getName(), world.getWorldFolder().toPath());
}, 3, TimeUnit.SECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return null;
} catch (ExecutionException | TimeoutException exception) {
return null;
}
}
private World resolveWorld(String worldKey) {
String baseLevelName = plugin.getServer().getWorlds().isEmpty()
? "world"
: plugin.getServer().getWorlds().get(0).getName();
return switch (worldKey) {
case "overworld" -> firstWorldByNames(List.of(baseLevelName, "world"), World.Environment.NORMAL);
case "nether" -> firstWorldByNames(List.of(baseLevelName + "_nether", "world_nether"), World.Environment.NETHER);
case "end" -> firstWorldByNames(List.of(baseLevelName + "_the_end", "world_the_end", baseLevelName + "_end"), World.Environment.THE_END);
default -> null;
};
}
private World firstWorldByNames(List<String> candidates, World.Environment fallbackEnvironment) {
for (String candidate : candidates) {
if (candidate == null || candidate.isBlank()) {
continue;
}
World world = plugin.getServer().getWorld(candidate);
if (world != null) {
return world;
}
}
return plugin.getServer().getWorlds().stream()
.filter(world -> world.getEnvironment() == fallbackEnvironment)
.findFirst()
.orElse(null);
}
private WorldBackupEntry toEntry(Path file, String worldKey) {
try {
String fileName = file.getFileName().toString();
String displayName = parseNameFromFileName(fileName);
long createdAt = Files.getLastModifiedTime(file).toMillis();
long sizeBytes = Files.size(file);
return new WorldBackupEntry(fileName, displayName, createdAt, sizeBytes, worldKey);
} catch (IOException exception) {
return null;
}
}
private String parseNameFromFileName(String fileName) {
int separatorIndex = fileName.indexOf("__");
if (separatorIndex < 0) {
return fileName;
}
String withoutPrefix = fileName.substring(separatorIndex + 2);
if (withoutPrefix.toLowerCase(Locale.ROOT).endsWith(".zip")) {
return withoutPrefix.substring(0, withoutPrefix.length() - 4);
}
return withoutPrefix;
}
private void zipDirectory(Path sourceDirectory, Path zipFile) throws IOException {
try (ZipOutputStream zipStream = new ZipOutputStream(Files.newOutputStream(zipFile, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE))) {
Files.walkFileTree(sourceDirectory, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attrs) throws IOException {
Path relative = sourceDirectory.relativize(directory);
if (!relative.toString().isEmpty()) {
String entryName = normalizeEntryName(relative) + "/";
try {
zipStream.putNextEntry(new ZipEntry(entryName));
zipStream.closeEntry();
} catch (IOException ignored) {
// Ignore duplicate/invalid directory entries and continue.
}
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (Files.isSymbolicLink(file)) {
return FileVisitResult.CONTINUE;
}
Path relative = sourceDirectory.relativize(file);
if (shouldSkipFile(relative)) {
return FileVisitResult.CONTINUE;
}
if (!Files.isReadable(file)) {
return FileVisitResult.CONTINUE;
}
String entryName = normalizeEntryName(relative);
try {
zipStream.putNextEntry(new ZipEntry(entryName));
Files.copy(file, zipStream);
zipStream.closeEntry();
} catch (IOException ignored) {
// Skip files that are temporarily locked by the OS or server process.
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
// Continue if a file cannot be read due to locks/permissions.
return FileVisitResult.CONTINUE;
}
});
} catch (UncheckedIOException exception) {
throw exception.getCause();
}
}
private boolean shouldSkipFile(Path relativePath) {
String name = relativePath.getFileName() == null ? "" : relativePath.getFileName().toString().toLowerCase(Locale.ROOT);
return "session.lock".equals(name);
}
private String normalizeEntryName(Path relativePath) {
return relativePath.toString().replace('\\', '/');
}
private String sanitizeBackupName(String rawName) {
if (rawName == null) {
return "";
}
StringBuilder sanitized = new StringBuilder();
String trimmed = rawName.trim();
for (int i = 0; i < trimmed.length(); i++) {
char c = trimmed.charAt(i);
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
sanitized.append(c);
} else if (c == ' ' || c == '.') {
sanitized.append('_');
}
}
String value = sanitized.toString().replaceAll("_+", "_");
if (value.length() > 48) {
return value.substring(0, 48);
}
return value;
}
private String normalizeWorldKey(String worldKey) {
if (worldKey == null) {
return null;
}
String normalized = worldKey.trim().toLowerCase(Locale.ROOT);
return WORLD_KEYS.contains(normalized) ? normalized : null;
}
public record WorldBackupEntry(String fileName, String name, long createdAt, long sizeBytes, String world) {
}
public record BackupCreateResult(boolean success, String error, String details, WorldBackupEntry backup) {
static BackupCreateResult success(WorldBackupEntry backup) {
return new BackupCreateResult(true, "", "", backup);
}
static BackupCreateResult error(String error) {
return new BackupCreateResult(false, error, "", null);
}
static BackupCreateResult error(String error, String details) {
return new BackupCreateResult(false, error, details == null ? "" : details, null);
}
}
public record BackupDeleteResult(boolean success, String error, String details, String fileName, String world) {
static BackupDeleteResult success(String fileName, String world) {
return new BackupDeleteResult(true, "", "", fileName, world);
}
static BackupDeleteResult error(String error) {
return new BackupDeleteResult(false, error, "", "", "");
}
static BackupDeleteResult error(String error, String details) {
return new BackupDeleteResult(false, error, details == null ? "" : details, "", "");
}
}
private record WorldSnapshot(String worldName, Path worldFolder) {
}
}
@@ -0,0 +1,145 @@
package de.winniepat.minePanel.extensions.worldbackups;
import com.google.gson.Gson;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.users.PanelPermission;
import java.nio.file.Path;
import java.util.*;
public final class WorldBackupsExtension implements MinePanelExtension {
private final Gson gson = new Gson();
private ExtensionContext context;
private WorldBackupService backupService;
@Override
public String id() {
return "world-backups";
}
@Override
public String displayName() {
return "World Backups";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
Path backupRoot = context.plugin().getDataFolder().toPath().resolve("backups");
this.backupService = new WorldBackupService(context.plugin(), backupRoot);
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/world-backups", PanelPermission.VIEW_BACKUPS, (request, response, user) -> {
String world = request.queryParams("world");
if (world != null && !world.isBlank()) {
List<Map<String, Object>> backups = backupService.listBackups(world).stream().map(this::toPayload).toList();
return webRegistry.json(response, 200, Map.of("world", world, "backups", backups));
}
Map<String, Object> payload = new HashMap<>();
for (String worldKey : backupService.supportedWorldKeys()) {
List<Map<String, Object>> backups = backupService.listBackups(worldKey).stream().map(this::toPayload).toList();
payload.put(worldKey, backups);
}
payload.put("worlds", backupService.supportedWorldKeys());
return webRegistry.json(response, 200, payload);
});
webRegistry.post("/api/extensions/world-backups/create", PanelPermission.MANAGE_BACKUPS, (request, response, user) -> {
CreateBackupPayload payload = gson.fromJson(request.body(), CreateBackupPayload.class);
if (payload == null || isBlank(payload.world()) || isBlank(payload.name())) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
WorldBackupService.BackupCreateResult result = backupService.createBackup(payload.world(), payload.name());
if (!result.success()) {
Map<String, Object> errorPayload = new HashMap<>();
errorPayload.put("error", result.error());
if (!isBlank(result.details())) {
errorPayload.put("details", result.details());
}
context.plugin().getLogger().warning("World backup failed for world='" + payload.world()
+ "', name='" + payload.name() + "': "
+ (isBlank(result.details()) ? result.error() : result.details()));
int status;
if ("invalid_world".equals(result.error()) || "invalid_name".equals(result.error())) {
status = 400;
} else if ("world_not_found".equals(result.error())) {
status = 404;
} else {
status = 500;
}
return webRegistry.json(response, status, errorPayload);
}
context.panelLogger().log("AUDIT", user.username(), "Created " + result.backup().world() + " backup: " + result.backup().fileName());
return webRegistry.json(response, 200, Map.of("ok", true, "backup", toPayload(result.backup())));
});
webRegistry.post("/api/extensions/world-backups/delete", PanelPermission.MANAGE_BACKUPS, (request, response, user) -> {
DeleteBackupPayload payload = gson.fromJson(request.body(), DeleteBackupPayload.class);
if (payload == null || isBlank(payload.world()) || isBlank(payload.fileName())) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
WorldBackupService.BackupDeleteResult result = backupService.deleteBackup(payload.world(), payload.fileName());
if (!result.success()) {
Map<String, Object> errorPayload = new HashMap<>();
errorPayload.put("error", result.error());
if (!isBlank(result.details())) {
errorPayload.put("details", result.details());
}
int status;
if ("invalid_world".equals(result.error()) || "invalid_file_name".equals(result.error())) {
status = 400;
} else if ("backup_not_found".equals(result.error())) {
status = 404;
} else {
status = 500;
}
return webRegistry.json(response, status, errorPayload);
}
context.panelLogger().log("AUDIT", user.username(), "Deleted " + result.world() + " backup: " + result.fileName());
return webRegistry.json(response, 200, Map.of("ok", true, "fileName", result.fileName(), "world", result.world()));
});
}
@Override
public List<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Backups", "/dashboard/world-backups"));
}
private Map<String, Object> toPayload(WorldBackupService.WorldBackupEntry backup) {
return Map.of(
"fileName", backup.fileName(),
"name", backup.name(),
"createdAt", backup.createdAt(),
"sizeBytes", backup.sizeBytes(),
"world", backup.world()
);
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private record CreateBackupPayload(String world, String name) {
}
private record DeleteBackupPayload(String world, String fileName) {
}
}
@@ -0,0 +1,37 @@
package de.winniepat.minePanel.integrations;
public record DiscordWebhookConfig(
boolean enabled,
String webhookUrl,
boolean useEmbed,
String botName,
String messageTemplate,
String embedTitleTemplate,
boolean logChat,
boolean logCommands,
boolean logAuth,
boolean logAudit,
boolean logSecurity,
boolean logConsoleResponse,
boolean logSystem
) {
public static DiscordWebhookConfig defaults() {
return new DiscordWebhookConfig(
false,
"",
true,
"MinePanel",
"[{timestamp}] [{kind}] [{source}] {message}",
"MinePanel {kind}",
true,
true,
true,
true,
true,
true,
true
);
}
}
@@ -0,0 +1,94 @@
package de.winniepat.minePanel.integrations;
import de.winniepat.minePanel.persistence.Database;
import java.sql.*;
public final class DiscordWebhookRepository {
private final Database database;
public DiscordWebhookRepository(Database database) {
this.database = database;
}
public DiscordWebhookConfig load() {
String sql = "SELECT enabled, webhook_url, use_embed, bot_name, message_template, embed_title_template, "
+ "log_chat, log_commands, log_auth, log_audit, log_security, log_console_response, log_system "
+ "FROM discord_webhook_config WHERE id = 1";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return new DiscordWebhookConfig(
resultSet.getInt("enabled") == 1,
resultSet.getString("webhook_url"),
resultSet.getInt("use_embed") == 1,
resultSet.getString("bot_name"),
resultSet.getString("message_template"),
resultSet.getString("embed_title_template"),
resultSet.getInt("log_chat") == 1,
resultSet.getInt("log_commands") == 1,
resultSet.getInt("log_auth") == 1,
resultSet.getInt("log_audit") == 1,
resultSet.getInt("log_security") == 1,
resultSet.getInt("log_console_response") == 1,
resultSet.getInt("log_system") == 1
);
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not load Discord webhook config", exception);
}
DiscordWebhookConfig defaults = DiscordWebhookConfig.defaults();
save(defaults);
return defaults;
}
public void save(DiscordWebhookConfig config) {
String sql = "INSERT INTO discord_webhook_config("
+ "id, enabled, webhook_url, use_embed, bot_name, message_template, embed_title_template, "
+ "log_chat, log_commands, log_auth, log_audit, log_security, log_console_response, log_system"
+ ") VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(id) DO UPDATE SET "
+ "enabled=excluded.enabled, "
+ "webhook_url=excluded.webhook_url, "
+ "use_embed=excluded.use_embed, "
+ "bot_name=excluded.bot_name, "
+ "message_template=excluded.message_template, "
+ "embed_title_template=excluded.embed_title_template, "
+ "log_chat=excluded.log_chat, "
+ "log_commands=excluded.log_commands, "
+ "log_auth=excluded.log_auth, "
+ "log_audit=excluded.log_audit, "
+ "log_security=excluded.log_security, "
+ "log_console_response=excluded.log_console_response, "
+ "log_system=excluded.log_system";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, config.enabled() ? 1 : 0);
statement.setString(2, nullSafe(config.webhookUrl()));
statement.setInt(3, config.useEmbed() ? 1 : 0);
statement.setString(4, nullSafe(config.botName()));
statement.setString(5, nullSafe(config.messageTemplate()));
statement.setString(6, nullSafe(config.embedTitleTemplate()));
statement.setInt(7, config.logChat() ? 1 : 0);
statement.setInt(8, config.logCommands() ? 1 : 0);
statement.setInt(9, config.logAuth() ? 1 : 0);
statement.setInt(10, config.logAudit() ? 1 : 0);
statement.setInt(11, config.logSecurity() ? 1 : 0);
statement.setInt(12, config.logConsoleResponse() ? 1 : 0);
statement.setInt(13, config.logSystem() ? 1 : 0);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not save Discord webhook config", exception);
}
}
private String nullSafe(String value) {
return value == null ? "" : value;
}
}
@@ -0,0 +1,136 @@
package de.winniepat.minePanel.integrations;
import com.google.gson.Gson;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
public final class DiscordWebhookService {
private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ISO_INSTANT;
private final Logger logger;
private final DiscordWebhookRepository repository;
private final AtomicReference<DiscordWebhookConfig> configReference;
private final ExecutorService senderExecutor;
private final HttpClient httpClient;
private final Gson gson;
public DiscordWebhookService(Logger logger, DiscordWebhookRepository repository) {
this.logger = logger;
this.repository = repository;
this.configReference = new AtomicReference<>(repository.load());
this.senderExecutor = Executors.newSingleThreadExecutor(task -> {
Thread thread = new Thread(task, "minepanel-discord-webhook");
thread.setDaemon(true);
return thread;
});
this.httpClient = HttpClient.newHttpClient();
this.gson = new Gson();
}
public DiscordWebhookConfig getConfig() {
return configReference.get();
}
public DiscordWebhookConfig updateConfig(DiscordWebhookConfig config) {
repository.save(config);
configReference.set(config);
return config;
}
public void handlePanelLog(String kind, String source, String message, Instant timestamp) {
DiscordWebhookConfig config = configReference.get();
if (!config.enabled() || config.webhookUrl().isBlank()) {
return;
}
if (!shouldSend(config, kind)) {
return;
}
String renderedMessage = renderTemplate(config.messageTemplate(), kind, source, message, timestamp);
String botName = config.botName().isBlank() ? "MinePanel" : config.botName();
senderExecutor.execute(() -> send(config, botName, kind, renderedMessage, source, timestamp));
}
public void shutdown() {
senderExecutor.shutdownNow();
}
private boolean shouldSend(DiscordWebhookConfig config, String kind) {
return switch (kind) {
case "CHAT" -> config.logChat();
case "COMMAND", "CONSOLE_COMMAND" -> config.logCommands();
case "AUTH" -> config.logAuth();
case "AUDIT" -> config.logAudit();
case "SECURITY" -> config.logSecurity();
case "CONSOLE_RESPONSE" -> config.logConsoleResponse();
case "SYSTEM" -> config.logSystem();
default -> false;
};
}
private void send(DiscordWebhookConfig config, String botName, String kind, String renderedMessage, String source, Instant timestamp) {
try {
Map<String, Object> payload;
if (config.useEmbed()) {
String title = renderTemplate(config.embedTitleTemplate(), kind, source, renderedMessage, timestamp);
payload = Map.of(
"username", botName,
"embeds", List.of(Map.of(
"title", truncate(title, 256),
"description", truncate(renderedMessage, 4096),
"timestamp", timestamp.toString(),
"color", 3447003
))
);
} else {
payload = Map.of(
"username", botName,
"content", truncate(renderedMessage, 2000)
);
}
HttpRequest request = HttpRequest.newBuilder(URI.create(config.webhookUrl()))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload), StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 300) {
logger.warning("Discord webhook returned status " + response.statusCode());
}
} catch (Exception exception) {
logger.warning("Discord webhook send failed: " + exception.getMessage());
}
}
private String renderTemplate(String template, String kind, String source, String message, Instant timestamp) {
String rawTemplate = (template == null || template.isBlank())
? "[{timestamp}] [{kind}] [{source}] {message}"
: template;
return rawTemplate
.replace("{timestamp}", TIMESTAMP_FORMAT.format(timestamp))
.replace("{kind}", kind == null ? "" : kind)
.replace("{source}", source == null ? "" : source)
.replace("{message}", message == null ? "" : message);
}
private String truncate(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value == null ? "" : value;
}
return value.substring(0, maxLength - 1) + "...";
}
}
@@ -0,0 +1,136 @@
package de.winniepat.minePanel.lifecycle;
import de.winniepat.minePanel.MinePanel;
import de.winniepat.minePanel.logs.*;
import de.winniepat.minePanel.persistence.*;
import de.winniepat.minePanel.web.BootstrapService;
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.*;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.logging.*;
public final class PluginLifecycleSupport {
private static final DateTimeFormatter EXPORT_FILE_NAME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
private PluginLifecycleSupport() {
}
@SuppressWarnings("all")
public static void configureThirdPartyStartupLogging() {
System.setProperty("org.eclipse.jetty.util.log.announce", "false");
System.setProperty("org.eclipse.jetty.LEVEL", "WARN");
Logger.getLogger("spark").setLevel(Level.WARNING);
Logger.getLogger("org.eclipse.jetty").setLevel(Level.WARNING);
Logger.getLogger("org.eclipse.jetty.util.log").setLevel(Level.WARNING);
try {
Class<?> levelClass = Class.forName("org.apache.logging.log4j.Level");
Class<?> configuratorClass = Class.forName("org.apache.logging.log4j.core.config.Configurator");
Method setLevelMethod = configuratorClass.getMethod("setLevel", String.class, levelClass);
Object warnLevel = levelClass.getField("WARN").get(null);
setLevelMethod.invoke(null, "spark", warnLevel);
setLevelMethod.invoke(null, "org.eclipse.jetty", warnLevel);
setLevelMethod.invoke(null, "org.eclipse.jetty.util.log", warnLevel);
} catch (ReflectiveOperationException ignored) {
}
}
public static void announceMinePanelBanner(JavaPlugin plugin, ComponentLogger componentLogger, MiniMessage miniMessage) {
plugin.getLogger().info("");
componentLogger.info(miniMessage.deserialize("<gold> __ __ ____ </gold>"));
componentLogger.info(miniMessage.deserialize("<gold>| \\/ | | _ \\ </gold>"));
componentLogger.info("{}{}", miniMessage.deserialize("<gold>| |\\/| | | |_) | MinePanel: </gold>"), miniMessage.deserialize("<green>" + plugin.getDescription().getVersion() + "</green>"));
componentLogger.info("{}{}", miniMessage.deserialize("<gold>| | | | | __/ Running on: </gold>"), miniMessage.deserialize("<aqua>" + plugin.getServer().getName() + "</aqua>" + "(" + plugin.getServer().getMinecraftVersion() + ")"));
componentLogger.info(miniMessage.deserialize("<gold>|_| |_| |_| </gold>"));
plugin.getLogger().info("");
}
public static void announceBootstrapToken(BootstrapService bootstrapService, ComponentLogger componentLogger, MiniMessage miniMessage) {
bootstrapService.getBootstrapToken().ifPresent(token -> {
componentLogger.info("{}{}", miniMessage.deserialize("<dark_green>First launch setup token: </dark_green>"), token);
componentLogger.info("");
});
}
public static void registerPluginListeners(
MinePanel plugin,
PanelLogger panelLogger,
KnownPlayerRepository knownPlayerRepository,
PlayerActivityRepository playerActivityRepository,
JoinLeaveEventRepository joinLeaveEventRepository
) {
plugin.getServer().getPluginManager().registerEvents(new ChatCaptureListener(panelLogger), plugin);
plugin.getServer().getPluginManager().registerEvents(new CommandCaptureListener(panelLogger), plugin);
plugin.getServer().getPluginManager().registerEvents(new PlayerActivityListener(plugin, knownPlayerRepository, playerActivityRepository, joinLeaveEventRepository, panelLogger), plugin);
}
public static void synchronizeKnownPlayers(Server server, KnownPlayerRepository knownPlayerRepository, PlayerActivityRepository playerActivityRepository) {
for (OfflinePlayer offlinePlayer : server.getOfflinePlayers()) {
if (offlinePlayer.getName() != null) {
knownPlayerRepository.upsert(offlinePlayer.getUniqueId(), offlinePlayer.getName());
playerActivityRepository.ensureFromOffline(
offlinePlayer.getUniqueId(),
Math.max(0L, offlinePlayer.getFirstPlayed()),
Math.max(0L, offlinePlayer.getLastPlayed())
);
}
}
server.getOnlinePlayers().forEach(player -> {
knownPlayerRepository.upsert(player.getUniqueId(), player.getName());
long now = Instant.now().toEpochMilli();
playerActivityRepository.onJoin(
player.getUniqueId(),
now,
player.getAddress() != null && player.getAddress().getAddress() != null
? player.getAddress().getAddress().getHostAddress()
: ""
);
});
}
public static void exportPanelLogsToServerLogsDirectory(Path dataFolder, LogRepository logRepository, Logger logger) {
try {
Path logsDirectory = dataFolder.resolve("logs");
Files.createDirectories(logsDirectory);
String timestamp = EXPORT_FILE_NAME_FORMAT.format(Instant.now().atZone(ZoneOffset.UTC));
Path exportFile = logsDirectory.resolve("minepanel-panel-" + timestamp + ".log");
StringBuilder output = new StringBuilder();
for (var entry : logRepository.allLogsAscending()) {
output.append(Instant.ofEpochMilli(entry.createdAt()))
.append(" [")
.append(entry.kind())
.append("] [")
.append(entry.source())
.append("] ")
.append(entry.message())
.append(System.lineSeparator());
}
if (output.isEmpty()) {
output.append(Instant.now()).append(" [SYSTEM] [PLUGIN] No panel logs available during shutdown export").append(System.lineSeparator());
}
Files.writeString(exportFile, output, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
logger.info("Exported panel logs to " + exportFile.toAbsolutePath());
} catch (IOException | IllegalStateException exception) {
logger.warning("Could not export panel logs on disable: " + exception.getMessage());
}
}
}
@@ -0,0 +1,23 @@
package de.winniepat.minePanel.logs;
import io.papermc.paper.event.player.AsyncChatEvent;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.event.*;
public final class ChatCaptureListener implements Listener {
private static final PlainTextComponentSerializer PLAIN_TEXT_SERIALIZER = PlainTextComponentSerializer.plainText();
private final PanelLogger panelLogger;
public ChatCaptureListener(PanelLogger panelLogger) {
this.panelLogger = panelLogger;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onChat(AsyncChatEvent event) {
String message = PLAIN_TEXT_SERIALIZER.serialize(event.message());
panelLogger.log("CHAT", event.getPlayer().getName(), message);
}
}
@@ -0,0 +1,25 @@
package de.winniepat.minePanel.logs;
import org.bukkit.event.*;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
import org.bukkit.event.server.ServerCommandEvent;
public final class CommandCaptureListener implements Listener {
private final PanelLogger panelLogger;
public CommandCaptureListener(PanelLogger panelLogger) {
this.panelLogger = panelLogger;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlayerCommand(PlayerCommandPreprocessEvent event) {
panelLogger.log("COMMAND", event.getPlayer().getName(), event.getMessage());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onServerCommand(ServerCommandEvent event) {
panelLogger.log("CONSOLE_COMMAND", "CONSOLE", event.getCommand());
}
}
@@ -0,0 +1,11 @@
package de.winniepat.minePanel.logs;
public record PanelLogEntry(
long id,
String kind,
String source,
String message,
long createdAt
) {
}
@@ -0,0 +1,26 @@
package de.winniepat.minePanel.logs;
import de.winniepat.minePanel.integrations.DiscordWebhookService;
import de.winniepat.minePanel.persistence.LogRepository;
import java.time.Instant;
public final class PanelLogger {
private final LogRepository logRepository;
private final DiscordWebhookService discordWebhookService;
public PanelLogger(LogRepository logRepository, DiscordWebhookService discordWebhookService) {
this.logRepository = logRepository;
this.discordWebhookService = discordWebhookService;
}
public synchronized void log(String kind, String source, String message) {
Instant now = Instant.now();
String safeMessage = message == null ? "" : message;
logRepository.appendLog(kind, source, safeMessage);
discordWebhookService.handlePanelLog(kind, source, safeMessage, now);
}
}
@@ -0,0 +1,144 @@
package de.winniepat.minePanel.logs;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import de.winniepat.minePanel.MinePanel;
import de.winniepat.minePanel.persistence.*;
import org.bukkit.entity.Player;
import org.bukkit.event.*;
import org.bukkit.event.player.*;
import java.net.*;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.UUID;
public final class PlayerActivityListener implements Listener {
private final MinePanel plugin;
private final KnownPlayerRepository knownPlayerRepository;
private final PlayerActivityRepository playerActivityRepository;
private final JoinLeaveEventRepository joinLeaveEventRepository;
private final PanelLogger panelLogger;
private final HttpClient httpClient = HttpClient.newHttpClient();
private final Gson gson = new Gson();
public PlayerActivityListener(
MinePanel plugin,
KnownPlayerRepository knownPlayerRepository,
PlayerActivityRepository playerActivityRepository,
JoinLeaveEventRepository joinLeaveEventRepository,
PanelLogger panelLogger
) {
this.plugin = plugin;
this.knownPlayerRepository = knownPlayerRepository;
this.playerActivityRepository = playerActivityRepository;
this.joinLeaveEventRepository = joinLeaveEventRepository;
this.panelLogger = panelLogger;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
UUID uuid = player.getUniqueId();
long now = Instant.now().toEpochMilli();
knownPlayerRepository.upsert(uuid, player.getName(), now);
joinLeaveEventRepository.appendJoinEvent(uuid, player.getName(), now);
String ip = "";
if (player.getAddress() != null && player.getAddress().getAddress() != null) {
ip = player.getAddress().getAddress().getHostAddress();
}
playerActivityRepository.onJoin(uuid, now, ip);
if (!ip.isBlank()) {
Player matchingOnlinePlayer = findOtherOnlinePlayerWithSameIp(player, ip);
if (matchingOnlinePlayer != null) {
panelLogger.log(
"SECURITY",
"ALT_DETECT",
"Possible alt account: " + player.getName()
+ " joined with IP " + ip
+ " already used by online player " + matchingOnlinePlayer.getName()
);
}
}
if (!ip.isBlank()) {
String ipAddress = ip;
plugin.schedulerBridge().runAsync(() -> {
String country = resolveCountry(ipAddress);
playerActivityRepository.updateCountry(uuid, country);
});
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerQuit(PlayerQuitEvent event) {
Player player = event.getPlayer();
long now = Instant.now().toEpochMilli();
knownPlayerRepository.upsert(player.getUniqueId(), player.getName(), now);
playerActivityRepository.onQuit(player.getUniqueId(), now);
joinLeaveEventRepository.appendLeaveEvent(player.getUniqueId(), player.getName(), now);
}
private String resolveCountry(String ipAddress) {
if (isLocalAddress(ipAddress)) {
return "Local";
}
try {
String encodedIp = URLEncoder.encode(ipAddress, StandardCharsets.UTF_8);
String url = "http://ip-api.com/json/" + encodedIp + "?fields=status,country";
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 300) {
return "Unknown";
}
JsonObject payload = gson.fromJson(response.body(), JsonObject.class);
if (payload == null || !payload.has("status")) {
return "Unknown";
}
if (!"success".equalsIgnoreCase(payload.get("status").getAsString())) {
return "Unknown";
}
if (!payload.has("country") || payload.get("country").isJsonNull()) {
return "Unknown";
}
return payload.get("country").getAsString();
} catch (Exception ignored) {
return "Unknown";
}
}
private boolean isLocalAddress(String ipAddress) {
try {
InetAddress address = InetAddress.getByName(ipAddress);
return address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isSiteLocalAddress();
} catch (Exception ignored) {
return true;
}
}
private Player findOtherOnlinePlayerWithSameIp(Player joinedPlayer, String ipAddress) {
for (Player online : plugin.getServer().getOnlinePlayers()) {
if (online.getUniqueId().equals(joinedPlayer.getUniqueId())) {
continue;
}
String onlineIp = "";
if (online.getAddress() != null && online.getAddress().getAddress() != null) {
onlineIp = online.getAddress().getAddress().getHostAddress();
}
if (!onlineIp.isBlank() && onlineIp.equals(ipAddress)) {
return online;
}
}
return null;
}
}
@@ -0,0 +1,88 @@
package de.winniepat.minePanel.logs;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.stream.*;
public final class ServerLogService {
private final Path logDirectory;
private final Path latestLogFile;
public ServerLogService(Path pluginDataFolder) {
Path pluginsFolder = pluginDataFolder.getParent();
Path serverRoot = pluginsFolder == null ? pluginDataFolder : pluginsFolder.getParent();
if (serverRoot == null) {
serverRoot = pluginDataFolder;
}
this.logDirectory = serverRoot.resolve("logs");
this.latestLogFile = logDirectory.resolve("latest.log");
}
public List<String> readLatestLines(int lines) {
return readLogLines("latest.log", lines);
}
public List<String> readLogLines(String logFileName, int lines) {
Path targetFile = resolveLogFile(logFileName);
if (targetFile == null || !Files.exists(targetFile)) {
return Collections.emptyList();
}
int safeLines = Math.max(1, Math.min(lines, 2000));
Deque<String> queue = new ArrayDeque<>(safeLines);
try {
for (String line : Files.readAllLines(targetFile, StandardCharsets.UTF_8)) {
if (queue.size() == safeLines) {
queue.removeFirst();
}
queue.addLast(line);
}
} catch (IOException exception) {
return List.of("Unable to read " + targetFile.getFileName() + ": " + exception.getMessage());
}
return new ArrayList<>(queue);
}
public List<String> listLogFiles() {
if (!Files.exists(logDirectory) || !Files.isDirectory(logDirectory)) {
return Collections.emptyList();
}
try (Stream<Path> files = Files.list(logDirectory)) {
return files
.filter(Files::isRegularFile)
.sorted(Comparator.comparingLong(this::lastModifiedSafe).reversed())
.map(path -> path.getFileName().toString())
.collect(Collectors.toList());
} catch (IOException exception) {
return Collections.emptyList();
}
}
private Path resolveLogFile(String logFileName) {
String selected = (logFileName == null || logFileName.isBlank()) ? "latest.log" : logFileName;
if (selected.contains("/") || selected.contains("\\")) {
return null;
}
Path target = logDirectory.resolve(selected).normalize();
if (!target.startsWith(logDirectory)) {
return null;
}
return target;
}
private long lastModifiedSafe(Path path) {
try {
return Files.getLastModifiedTime(path).toMillis();
} catch (IOException ignored) {
return Long.MIN_VALUE;
}
}
}
@@ -0,0 +1,143 @@
package de.winniepat.minePanel.persistence;
import java.nio.file.*;
import java.sql.*;
public final class Database {
private final Path databaseFile;
public Database(Path databaseFile) {
this.databaseFile = databaseFile;
}
public void initialize() {
try {
Files.createDirectories(databaseFile.getParent());
} catch (Exception exception) {
throw new IllegalStateException("Could not create database directory", exception);
}
try (Connection connection = getConnection();
Statement statement = connection.createStatement()) {
statement.execute("CREATE TABLE IF NOT EXISTS users ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "username TEXT NOT NULL UNIQUE,"
+ "password_hash TEXT NOT NULL,"
+ "role TEXT NOT NULL,"
+ "created_at INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS user_permissions ("
+ "user_id INTEGER NOT NULL,"
+ "permission TEXT NOT NULL,"
+ "PRIMARY KEY(user_id, permission),"
+ "FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS sessions ("
+ "token TEXT PRIMARY KEY,"
+ "user_id INTEGER NOT NULL,"
+ "created_at INTEGER NOT NULL,"
+ "expires_at INTEGER NOT NULL,"
+ "FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS oauth_accounts ("
+ "user_id INTEGER NOT NULL,"
+ "provider TEXT NOT NULL,"
+ "provider_user_id TEXT NOT NULL,"
+ "display_name TEXT NOT NULL DEFAULT '',"
+ "email TEXT NOT NULL DEFAULT '',"
+ "avatar_url TEXT NOT NULL DEFAULT '',"
+ "linked_at INTEGER NOT NULL,"
+ "updated_at INTEGER NOT NULL,"
+ "PRIMARY KEY(user_id, provider),"
+ "UNIQUE(provider, provider_user_id),"
+ "FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS oauth_states ("
+ "state TEXT PRIMARY KEY,"
+ "provider TEXT NOT NULL,"
+ "mode TEXT NOT NULL,"
+ "user_id INTEGER,"
+ "created_at INTEGER NOT NULL,"
+ "expires_at INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS panel_logs ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "kind TEXT NOT NULL,"
+ "source TEXT NOT NULL,"
+ "message TEXT NOT NULL,"
+ "created_at INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS known_players ("
+ "uuid TEXT PRIMARY KEY,"
+ "username TEXT NOT NULL,"
+ "last_seen_at INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS player_activity ("
+ "uuid TEXT PRIMARY KEY,"
+ "first_joined INTEGER NOT NULL DEFAULT 0,"
+ "last_seen INTEGER NOT NULL DEFAULT 0,"
+ "total_playtime_seconds INTEGER NOT NULL DEFAULT 0,"
+ "total_sessions INTEGER NOT NULL DEFAULT 0,"
+ "current_session_start INTEGER NOT NULL DEFAULT 0,"
+ "last_ip TEXT NOT NULL DEFAULT '',"
+ "last_country TEXT NOT NULL DEFAULT ''"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS join_leave_events ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "event_type TEXT NOT NULL,"
+ "player_uuid TEXT NOT NULL,"
+ "player_name TEXT NOT NULL,"
+ "created_at INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS discord_webhook_config ("
+ "id INTEGER PRIMARY KEY,"
+ "enabled INTEGER NOT NULL,"
+ "webhook_url TEXT NOT NULL,"
+ "use_embed INTEGER NOT NULL,"
+ "bot_name TEXT NOT NULL,"
+ "message_template TEXT NOT NULL,"
+ "embed_title_template TEXT NOT NULL,"
+ "log_chat INTEGER NOT NULL,"
+ "log_commands INTEGER NOT NULL,"
+ "log_auth INTEGER NOT NULL,"
+ "log_audit INTEGER NOT NULL,"
+ "log_security INTEGER NOT NULL DEFAULT 1,"
+ "log_console_response INTEGER NOT NULL,"
+ "log_system INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS extension_settings ("
+ "extension_id TEXT PRIMARY KEY,"
+ "settings_json TEXT NOT NULL DEFAULT '{}',"
+ "updated_at INTEGER NOT NULL DEFAULT 0"
+ ")");
try {
statement.execute("ALTER TABLE discord_webhook_config ADD COLUMN log_security INTEGER NOT NULL DEFAULT 1");
} catch (SQLException ignored) {
// Column already exists on upgraded installs.
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not initialize database", exception);
}
}
public Connection getConnection() throws SQLException {
return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.toAbsolutePath());
}
public void close() {
}
}
@@ -0,0 +1,64 @@
package de.winniepat.minePanel.persistence;
import java.sql.*;
import java.util.Optional;
public final class ExtensionSettingsRepository {
private final Database database;
public ExtensionSettingsRepository(Database database) {
this.database = database;
}
public Optional<String> findSettingsJson(String extensionId) {
String normalizedId = normalizeExtensionId(extensionId);
if (normalizedId.isBlank()) {
return Optional.empty();
}
String sql = "SELECT settings_json FROM extension_settings WHERE extension_id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, normalizedId);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.ofNullable(resultSet.getString("settings_json"));
}
}
return Optional.empty();
} catch (SQLException exception) {
throw new IllegalStateException("Could not read extension settings", exception);
}
}
public void saveSettingsJson(String extensionId, String settingsJson, long updatedAtMillis) {
String normalizedId = normalizeExtensionId(extensionId);
if (normalizedId.isBlank()) {
throw new IllegalArgumentException("invalid_extension_id");
}
String sql = "INSERT INTO extension_settings(extension_id, settings_json, updated_at) VALUES (?, ?, ?) "
+ "ON CONFLICT(extension_id) DO UPDATE SET "
+ "settings_json = excluded.settings_json, "
+ "updated_at = excluded.updated_at";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, normalizedId);
statement.setString(2, settingsJson == null || settingsJson.isBlank() ? "{}" : settingsJson);
statement.setLong(3, Math.max(0L, updatedAtMillis));
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not save extension settings", exception);
}
}
private String normalizeExtensionId(String extensionId) {
if (extensionId == null || extensionId.isBlank()) {
return "";
}
return extensionId.trim().toLowerCase();
}
}
@@ -0,0 +1,66 @@
package de.winniepat.minePanel.persistence;
import java.sql.*;
import java.util.*;
public final class JoinLeaveEventRepository {
private final Database database;
public JoinLeaveEventRepository(Database database) {
this.database = database;
}
public void appendJoinEvent(UUID uuid, String username, long createdAt) {
appendEvent("JOIN", uuid, username, createdAt);
}
public void appendLeaveEvent(UUID uuid, String username, long createdAt) {
appendEvent("LEAVE", uuid, username, createdAt);
}
public List<JoinLeaveEvent> listEventsSince(long sinceMillis) {
String sql = "SELECT event_type, player_uuid, player_name, created_at "
+ "FROM join_leave_events WHERE created_at >= ? ORDER BY created_at ASC";
List<JoinLeaveEvent> events = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, Math.max(0L, sinceMillis));
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
events.add(new JoinLeaveEvent(
resultSet.getString("event_type"),
UUID.fromString(resultSet.getString("player_uuid")),
resultSet.getString("player_name"),
resultSet.getLong("created_at")
));
}
}
return events;
} catch (SQLException exception) {
throw new IllegalStateException("Could not list join/leave events", exception);
}
}
private void appendEvent(String eventType, UUID uuid, String username, long createdAt) {
if (uuid == null || username == null || username.isBlank()) {
return;
}
String sql = "INSERT INTO join_leave_events(event_type, player_uuid, player_name, created_at) VALUES (?, ?, ?, ?)";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, eventType);
statement.setString(2, uuid.toString());
statement.setString(3, username);
statement.setLong(4, createdAt);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not append join/leave event", exception);
}
}
public record JoinLeaveEvent(String eventType, UUID playerUuid, String playerName, long createdAt) {
}
}
@@ -0,0 +1,11 @@
package de.winniepat.minePanel.persistence;
import java.util.UUID;
public record KnownPlayer(
UUID uuid,
String username,
long lastSeenAt
) {
}
@@ -0,0 +1,100 @@
package de.winniepat.minePanel.persistence;
import java.sql.*;
import java.time.Instant;
import java.util.*;
public final class KnownPlayerRepository {
private final Database database;
public KnownPlayerRepository(Database database) {
this.database = database;
}
public void upsert(UUID uuid, String username) {
upsert(uuid, username, Instant.now().toEpochMilli());
}
public void upsert(UUID uuid, String username, long lastSeenAt) {
if (uuid == null || username == null || username.isBlank()) {
return;
}
String sql = "INSERT INTO known_players(uuid, username, last_seen_at) VALUES (?, ?, ?) "
+ "ON CONFLICT(uuid) DO UPDATE SET username = excluded.username, last_seen_at = excluded.last_seen_at";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
statement.setString(2, username);
statement.setLong(3, lastSeenAt);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not upsert known player", exception);
}
}
public List<KnownPlayer> findAll() {
String sql = "SELECT uuid, username, last_seen_at FROM known_players";
List<KnownPlayer> players = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
players.add(new KnownPlayer(
UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username"),
resultSet.getLong("last_seen_at")
));
}
return players;
} catch (SQLException exception) {
throw new IllegalStateException("Could not list known players", exception);
}
}
public Optional<KnownPlayer> findByUuid(UUID uuid) {
String sql = "SELECT uuid, username, last_seen_at FROM known_players WHERE uuid = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(new KnownPlayer(
UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username"),
resultSet.getLong("last_seen_at")
));
}
return Optional.empty();
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not find known player", exception);
}
}
public Optional<KnownPlayer> findByUsername(String username) {
if (username == null || username.isBlank()) {
return Optional.empty();
}
String sql = "SELECT uuid, username, last_seen_at FROM known_players WHERE LOWER(username) = LOWER(?) LIMIT 1";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, username.trim());
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(new KnownPlayer(
UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username"),
resultSet.getLong("last_seen_at")
));
}
return Optional.empty();
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not find known player by username", exception);
}
}
}
@@ -0,0 +1,97 @@
package de.winniepat.minePanel.persistence;
import de.winniepat.minePanel.logs.PanelLogEntry;
import java.sql.*;
import java.time.Instant;
import java.util.*;
public final class LogRepository {
private final Database database;
public LogRepository(Database database) {
this.database = database;
}
public void appendLog(String kind, String source, String message) {
String sql = "INSERT INTO panel_logs(kind, source, message, created_at) VALUES (?, ?, ?, ?)";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, kind);
statement.setString(2, source);
statement.setString(3, message);
statement.setLong(4, Instant.now().toEpochMilli());
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not append panel log", exception);
}
}
public List<PanelLogEntry> recentLogs(int limit) {
int safeLimit = Math.max(1, Math.min(limit, 1000));
String sql = "SELECT id, kind, source, message, created_at FROM panel_logs ORDER BY id DESC LIMIT ?";
List<PanelLogEntry> entries = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, safeLimit);
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
entries.add(new PanelLogEntry(
resultSet.getLong("id"),
resultSet.getString("kind"),
resultSet.getString("source"),
resultSet.getString("message"),
resultSet.getLong("created_at")
));
}
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not query panel logs", exception);
}
return entries;
}
public List<PanelLogEntry> allLogsAscending() {
String sql = "SELECT id, kind, source, message, created_at FROM panel_logs ORDER BY id ASC";
List<PanelLogEntry> entries = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
entries.add(new PanelLogEntry(
resultSet.getLong("id"),
resultSet.getString("kind"),
resultSet.getString("source"),
resultSet.getString("message"),
resultSet.getLong("created_at")
));
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not query all panel logs", exception);
}
return entries;
}
public long latestLogId() {
String sql = "SELECT COALESCE(MAX(id), 0) AS latest_id FROM panel_logs";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
return resultSet.getLong("latest_id");
} catch (SQLException exception) {
throw new IllegalStateException("Could not get latest panel log id", exception);
}
}
public void clearLogs() {
String sql = "DELETE FROM panel_logs";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not clear panel logs", exception);
}
}
}
@@ -0,0 +1,153 @@
package de.winniepat.minePanel.persistence;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
public final class OAuthAccountRepository {
private final Database database;
public OAuthAccountRepository(Database database) {
this.database = database;
}
public Optional<Long> findUserIdByProviderSubject(String provider, String providerUserId) {
String normalizedProvider = normalizeProvider(provider);
String sql = "SELECT user_id FROM oauth_accounts WHERE provider = ? AND provider_user_id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, normalizedProvider);
statement.setString(2, providerUserId);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(resultSet.getLong("user_id"));
}
}
return Optional.empty();
} catch (SQLException exception) {
throw new IllegalStateException("Could not query OAuth account by provider subject", exception);
}
}
public Optional<OAuthAccountLink> findByUserAndProvider(long userId, String provider) {
String normalizedProvider = normalizeProvider(provider);
String sql = "SELECT user_id, provider, provider_user_id, display_name, email, avatar_url, linked_at, updated_at "
+ "FROM oauth_accounts WHERE user_id = ? AND provider = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
statement.setString(2, normalizedProvider);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(map(resultSet));
}
}
return Optional.empty();
} catch (SQLException exception) {
throw new IllegalStateException("Could not query OAuth account by user and provider", exception);
}
}
public List<OAuthAccountLink> listByUserId(long userId) {
String sql = "SELECT user_id, provider, provider_user_id, display_name, email, avatar_url, linked_at, updated_at "
+ "FROM oauth_accounts WHERE user_id = ? ORDER BY provider ASC";
List<OAuthAccountLink> links = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
links.add(map(resultSet));
}
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not list OAuth links for user", exception);
}
return links;
}
public void upsertLink(long userId, String provider, String providerUserId, String displayName, String email, String avatarUrl) {
String normalizedProvider = normalizeProvider(provider);
long now = Instant.now().toEpochMilli();
String sql = "INSERT INTO oauth_accounts(user_id, provider, provider_user_id, display_name, email, avatar_url, linked_at, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(user_id, provider) DO UPDATE SET "
+ "provider_user_id = excluded.provider_user_id, "
+ "display_name = excluded.display_name, "
+ "email = excluded.email, "
+ "avatar_url = excluded.avatar_url, "
+ "updated_at = excluded.updated_at";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
statement.setString(2, normalizedProvider);
statement.setString(3, providerUserId);
statement.setString(4, safe(displayName));
statement.setString(5, safe(email));
statement.setString(6, safe(avatarUrl));
statement.setLong(7, now);
statement.setLong(8, now);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not upsert OAuth account link", exception);
}
}
public boolean unlink(long userId, String provider) {
String normalizedProvider = normalizeProvider(provider);
String sql = "DELETE FROM oauth_accounts WHERE user_id = ? AND provider = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
statement.setString(2, normalizedProvider);
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not unlink OAuth account", exception);
}
}
private OAuthAccountLink map(ResultSet resultSet) throws SQLException {
return new OAuthAccountLink(
resultSet.getLong("user_id"),
resultSet.getString("provider"),
resultSet.getString("provider_user_id"),
resultSet.getString("display_name"),
resultSet.getString("email"),
resultSet.getString("avatar_url"),
resultSet.getLong("linked_at"),
resultSet.getLong("updated_at")
);
}
private String normalizeProvider(String provider) {
return provider == null ? "" : provider.trim().toLowerCase(Locale.ROOT);
}
private String safe(String value) {
return value == null ? "" : value.trim();
}
public record OAuthAccountLink(
long userId,
String provider,
String providerUserId,
String displayName,
String email,
String avatarUrl,
long linkedAt,
long updatedAt
) {
}
}
@@ -0,0 +1,122 @@
package de.winniepat.minePanel.persistence;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.Locale;
import java.util.Optional;
public final class OAuthStateRepository {
private final Database database;
public OAuthStateRepository(Database database) {
this.database = database;
}
public void createState(String state, String provider, String mode, Long userId, long expiresAtMillis) {
cleanupExpired();
String sql = "INSERT INTO oauth_states(state, provider, mode, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)";
long now = Instant.now().toEpochMilli();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, state);
statement.setString(2, normalize(provider));
statement.setString(3, normalize(mode));
if (userId == null) {
statement.setNull(4, java.sql.Types.INTEGER);
} else {
statement.setLong(4, userId);
}
statement.setLong(5, now);
statement.setLong(6, expiresAtMillis);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not create OAuth state", exception);
}
}
public Optional<OAuthState> consumeState(String state, String provider, String mode) {
String normalizedProvider = normalize(provider);
String normalizedMode = normalize(mode);
String selectSql = "SELECT state, provider, mode, user_id, created_at, expires_at FROM oauth_states WHERE state = ? AND provider = ? AND mode = ?";
String deleteSql = "DELETE FROM oauth_states WHERE state = ?";
try (Connection connection = database.getConnection();
PreparedStatement selectStatement = connection.prepareStatement(selectSql);
PreparedStatement deleteStatement = connection.prepareStatement(deleteSql)) {
connection.setAutoCommit(false);
selectStatement.setString(1, state);
selectStatement.setString(2, normalizedProvider);
selectStatement.setString(3, normalizedMode);
OAuthState oauthState = null;
try (ResultSet resultSet = selectStatement.executeQuery()) {
if (resultSet.next()) {
Long userId = null;
long rawUserId = resultSet.getLong("user_id");
if (!resultSet.wasNull()) {
userId = rawUserId;
}
oauthState = new OAuthState(
resultSet.getString("state"),
resultSet.getString("provider"),
resultSet.getString("mode"),
userId,
resultSet.getLong("created_at"),
resultSet.getLong("expires_at")
);
}
}
if (oauthState == null) {
connection.rollback();
return Optional.empty();
}
deleteStatement.setString(1, state);
deleteStatement.executeUpdate();
if (oauthState.expiresAtMillis() <= Instant.now().toEpochMilli()) {
connection.commit();
return Optional.empty();
}
connection.commit();
return Optional.of(oauthState);
} catch (SQLException exception) {
throw new IllegalStateException("Could not consume OAuth state", exception);
}
}
public void cleanupExpired() {
String sql = "DELETE FROM oauth_states WHERE expires_at <= ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, Instant.now().toEpochMilli());
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not cleanup OAuth states", exception);
}
}
private String normalize(String raw) {
return raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT);
}
public record OAuthState(
String state,
String provider,
String mode,
Long userId,
long createdAtMillis,
long expiresAtMillis
) {
}
}
@@ -0,0 +1,16 @@
package de.winniepat.minePanel.persistence;
import java.util.UUID;
public record PlayerActivity(
UUID uuid,
long firstJoined,
long lastSeen,
long totalPlaytimeSeconds,
long totalSessions,
long currentSessionStart,
String lastIp,
String lastCountry
) {
}
@@ -0,0 +1,152 @@
package de.winniepat.minePanel.persistence;
import java.sql.*;
import java.util.*;
public final class PlayerActivityRepository {
private final Database database;
public PlayerActivityRepository(Database database) {
this.database = database;
}
public void ensureFromOffline(UUID uuid, long firstJoined, long lastSeen) {
if (uuid == null) {
return;
}
long safeFirstJoined = Math.max(0L, firstJoined);
long safeLastSeen = Math.max(0L, lastSeen);
String sql = "INSERT INTO player_activity(uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country) "
+ "VALUES (?, ?, ?, 0, 0, 0, '', '') "
+ "ON CONFLICT(uuid) DO UPDATE SET "
+ "first_joined = CASE WHEN player_activity.first_joined = 0 THEN excluded.first_joined ELSE player_activity.first_joined END, "
+ "last_seen = MAX(player_activity.last_seen, excluded.last_seen)";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
statement.setLong(2, safeFirstJoined);
statement.setLong(3, safeLastSeen);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not seed player activity", exception);
}
}
public void onJoin(UUID uuid, long joinedAt, String ipAddress) {
if (uuid == null) {
return;
}
String safeIp = ipAddress == null ? "" : ipAddress;
String sql = "INSERT INTO player_activity(uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country) "
+ "VALUES (?, ?, ?, 0, 1, ?, ?, '') "
+ "ON CONFLICT(uuid) DO UPDATE SET "
+ "first_joined = CASE WHEN player_activity.first_joined = 0 THEN excluded.first_joined ELSE player_activity.first_joined END, "
+ "last_seen = excluded.last_seen, "
+ "total_sessions = player_activity.total_sessions + 1, "
+ "current_session_start = CASE WHEN player_activity.current_session_start > 0 THEN player_activity.current_session_start ELSE excluded.current_session_start END, "
+ "last_ip = excluded.last_ip";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
statement.setLong(2, joinedAt);
statement.setLong(3, joinedAt);
statement.setLong(4, joinedAt);
statement.setString(5, safeIp);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not update player activity on join", exception);
}
}
public void onQuit(UUID uuid, long quitAt) {
if (uuid == null) {
return;
}
String sql = "UPDATE player_activity SET "
+ "last_seen = ?, "
+ "total_playtime_seconds = total_playtime_seconds + CASE "
+ "WHEN current_session_start > 0 AND ? > current_session_start THEN (? - current_session_start) / 1000 ELSE 0 END, "
+ "current_session_start = 0 "
+ "WHERE uuid = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, quitAt);
statement.setLong(2, quitAt);
statement.setLong(3, quitAt);
statement.setString(4, uuid.toString());
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not update player activity on quit", exception);
}
}
public void updateCountry(UUID uuid, String country) {
if (uuid == null) {
return;
}
String sql = "UPDATE player_activity SET last_country = ? WHERE uuid = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, country == null ? "" : country);
statement.setString(2, uuid.toString());
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not update player country", exception);
}
}
public Optional<PlayerActivity> findByUuid(UUID uuid) {
String sql = "SELECT uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country "
+ "FROM player_activity WHERE uuid = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(read(resultSet));
}
}
return Optional.empty();
} catch (SQLException exception) {
throw new IllegalStateException("Could not read player activity", exception);
}
}
public Map<UUID, PlayerActivity> findAllByUuid() {
String sql = "SELECT uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country FROM player_activity";
Map<UUID, PlayerActivity> players = new HashMap<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
PlayerActivity entry = read(resultSet);
players.put(entry.uuid(), entry);
}
return players;
} catch (SQLException exception) {
throw new IllegalStateException("Could not list player activity", exception);
}
}
private PlayerActivity read(ResultSet resultSet) throws SQLException {
return new PlayerActivity(
UUID.fromString(resultSet.getString("uuid")),
resultSet.getLong("first_joined"),
resultSet.getLong("last_seen"),
resultSet.getLong("total_playtime_seconds"),
resultSet.getLong("total_sessions"),
resultSet.getLong("current_session_start"),
resultSet.getString("last_ip"),
resultSet.getString("last_country")
);
}
}
@@ -0,0 +1,317 @@
package de.winniepat.minePanel.persistence;
import de.winniepat.minePanel.users.*;
import java.sql.*;
import java.time.Instant;
import java.util.*;
public final class UserRepository {
private final Database database;
public UserRepository(Database database) {
this.database = database;
}
public long countUsers() {
String sql = "SELECT COUNT(*) AS amount FROM users";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
return resultSet.getLong("amount");
} catch (SQLException exception) {
throw new IllegalStateException("Could not count users", exception);
}
}
public long countOwners() {
String sql = "SELECT COUNT(*) AS amount FROM users WHERE role = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, UserRole.OWNER.name());
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.getLong("amount");
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not count owners", exception);
}
}
public int demoteExtraOwnersToAdmin() {
String selectSql = "SELECT id FROM users WHERE role = ? ORDER BY id ASC";
String updateSql = "UPDATE users SET role = ? WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement selectStatement = connection.prepareStatement(selectSql);
PreparedStatement updateStatement = connection.prepareStatement(updateSql)) {
connection.setAutoCommit(false);
selectStatement.setString(1, UserRole.OWNER.name());
List<Long> ownerIds = new ArrayList<>();
try (ResultSet resultSet = selectStatement.executeQuery()) {
while (resultSet.next()) {
ownerIds.add(resultSet.getLong("id"));
}
}
if (ownerIds.size() <= 1) {
connection.rollback();
return 0;
}
int updated = 0;
for (int i = 1; i < ownerIds.size(); i++) {
long userId = ownerIds.get(i);
updateStatement.setString(1, UserRole.ADMIN.name());
updateStatement.setLong(2, userId);
if (updateStatement.executeUpdate() > 0) {
savePermissions(connection, userId, UserRole.ADMIN.defaultPermissions());
updated++;
}
}
connection.commit();
return updated;
} catch (SQLException exception) {
throw new IllegalStateException("Could not normalize owner accounts", exception);
}
}
public PanelUser createUser(String username, String passwordHash, UserRole role) {
return createUser(username, passwordHash, role, role.defaultPermissions());
}
public PanelUser createUser(String username, String passwordHash, UserRole role, Set<PanelPermission> permissions) {
String sql = "INSERT INTO users(username, password_hash, role, created_at) VALUES (?, ?, ?, ?)";
long createdAt = Instant.now().toEpochMilli();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS)) {
connection.setAutoCommit(false);
statement.setString(1, username);
statement.setString(2, passwordHash);
statement.setString(3, role.name());
statement.setLong(4, createdAt);
statement.executeUpdate();
long userId;
try (ResultSet keys = statement.getGeneratedKeys()) {
if (keys.next()) {
userId = keys.getLong(1);
} else {
throw new IllegalStateException("Could not create user (missing generated key)");
}
}
Set<PanelPermission> sanitized = sanitizePermissions(permissions, role);
savePermissions(connection, userId, sanitized);
connection.commit();
return new PanelUser(userId, username, role, sanitized, createdAt);
} catch (SQLException exception) {
throw new IllegalStateException("Could not create user", exception);
}
}
public Optional<PanelUserAuth> findByUsername(String username) {
String sql = "SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, username);
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next()) {
return Optional.empty();
}
PanelUser user = mapUser(connection, resultSet);
String passwordHash = resultSet.getString("password_hash");
return Optional.of(new PanelUserAuth(user, passwordHash));
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not find user by username", exception);
}
}
public Optional<PanelUser> findById(long userId) {
String sql = "SELECT id, username, role, created_at FROM users WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(mapUser(connection, resultSet));
}
return Optional.empty();
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not find user by id", exception);
}
}
public List<PanelUser> findAllUsers() {
String sql = "SELECT id, username, role, created_at FROM users ORDER BY id ASC";
String permissionSql = "SELECT user_id, permission FROM user_permissions ORDER BY user_id ASC";
List<PanelUser> users = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement permissionStatement = connection.prepareStatement(permissionSql);
ResultSet permissionResultSet = permissionStatement.executeQuery();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
Map<Long, Set<PanelPermission>> permissionsByUser = new HashMap<>();
while (permissionResultSet.next()) {
long userId = permissionResultSet.getLong("user_id");
String rawPermission = permissionResultSet.getString("permission");
PanelPermission permission;
try {
permission = PanelPermission.valueOf(rawPermission);
} catch (IllegalArgumentException ignored) {
continue;
}
permissionsByUser.computeIfAbsent(userId, ignored -> EnumSet.noneOf(PanelPermission.class)).add(permission);
}
while (resultSet.next()) {
long userId = resultSet.getLong("id");
UserRole role = UserRole.fromString(resultSet.getString("role"));
Set<PanelPermission> permissions = permissionsByUser.getOrDefault(userId, role.defaultPermissions());
users.add(new PanelUser(
userId,
resultSet.getString("username"),
role,
EnumSet.copyOf(permissions),
resultSet.getLong("created_at")
));
}
return users;
} catch (SQLException exception) {
throw new IllegalStateException("Could not list users", exception);
}
}
public boolean updateRole(long userId, UserRole role) {
String sql = "UPDATE users SET role = ? WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
connection.setAutoCommit(false);
statement.setString(1, role.name());
statement.setLong(2, userId);
boolean changed = statement.executeUpdate() > 0;
if (!changed) {
connection.rollback();
return false;
}
savePermissions(connection, userId, role.defaultPermissions());
connection.commit();
return true;
} catch (SQLException exception) {
throw new IllegalStateException("Could not update user role", exception);
}
}
public boolean updatePermissions(long userId, Set<PanelPermission> permissions) {
Optional<PanelUser> target = findById(userId);
if (target.isEmpty()) {
return false;
}
Set<PanelPermission> sanitized = sanitizePermissions(permissions, target.get().role());
String sql = "SELECT id FROM users WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
connection.setAutoCommit(false);
statement.setLong(1, userId);
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next()) {
connection.rollback();
return false;
}
}
savePermissions(connection, userId, sanitized);
connection.commit();
return true;
} catch (SQLException exception) {
throw new IllegalStateException("Could not update user permissions", exception);
}
}
public boolean deleteUser(long userId) {
String sql = "DELETE FROM users WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not delete user", exception);
}
}
private PanelUser mapUser(Connection connection, ResultSet resultSet) throws SQLException {
long userId = resultSet.getLong("id");
UserRole role = UserRole.fromString(resultSet.getString("role"));
return new PanelUser(
userId,
resultSet.getString("username"),
role,
loadPermissions(connection, userId, role),
resultSet.getLong("created_at")
);
}
private Set<PanelPermission> loadPermissions(Connection connection, long userId, UserRole role) throws SQLException {
String sql = "SELECT permission FROM user_permissions WHERE user_id = ?";
Set<PanelPermission> permissions = EnumSet.noneOf(PanelPermission.class);
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
String rawPermission = resultSet.getString("permission");
try {
permissions.add(PanelPermission.valueOf(rawPermission));
} catch (IllegalArgumentException ignored) {
// Ignore unknown permissions from older/newer versions.
}
}
}
}
if (permissions.isEmpty()) {
return role.defaultPermissions();
}
return permissions;
}
private void savePermissions(Connection connection, long userId, Set<PanelPermission> permissions) throws SQLException {
try (PreparedStatement deleteStatement = connection.prepareStatement("DELETE FROM user_permissions WHERE user_id = ?")) {
deleteStatement.setLong(1, userId);
deleteStatement.executeUpdate();
}
String insertSql = "INSERT INTO user_permissions(user_id, permission) VALUES (?, ?)";
try (PreparedStatement insertStatement = connection.prepareStatement(insertSql)) {
for (PanelPermission permission : permissions) {
insertStatement.setLong(1, userId);
insertStatement.setString(2, permission.name());
insertStatement.addBatch();
}
insertStatement.executeBatch();
}
}
private Set<PanelPermission> sanitizePermissions(Set<PanelPermission> permissions, UserRole role) {
if (role == UserRole.OWNER) {
return EnumSet.allOf(PanelPermission.class);
}
Set<PanelPermission> source = permissions == null || permissions.isEmpty() ? role.defaultPermissions() : permissions;
Set<PanelPermission> sanitized = EnumSet.noneOf(PanelPermission.class);
for (PanelPermission permission : source) {
if (permission == null || !permission.assignable()) {
continue;
}
sanitized.add(permission);
}
sanitized.add(PanelPermission.ACCESS_PANEL);
return sanitized;
}
}
@@ -0,0 +1,77 @@
package de.winniepat.minePanel.users;
public enum PanelPermission {
// Legacy keys kept for compatibility with older route guards.
VIEW_DASHBOARD("Legacy", "View Dashboard", null, false),
VIEW_LOGS("Legacy", "View Logs", null, false),
ACCESS_PANEL("Panel", "Access Panel", null, true),
VIEW_OVERVIEW("Server", "View Overview", null, true),
VIEW_CONSOLE("Server", "View Console", null, true),
SEND_CONSOLE("Server", "Send Console Commands", null, true),
VIEW_RESOURCES("Server", "View Resources", null, true),
VIEW_PLAYERS("Server", "View Players", null, true),
MANAGE_PLAYERS("Server", "Manage Players", null, true),
VIEW_BANS("Server", "View Bans", null, true),
MANAGE_BANS("Server", "Manage Bans", null, true),
VIEW_PLUGINS("Server", "View Plugins", null, true),
MANAGE_PLUGINS("Server", "Install Plugins", null, true),
VIEW_USERS("Panel", "View Users", null, true),
MANAGE_USERS("Panel", "Manage Users", null, true),
VIEW_DISCORD_WEBHOOK("Panel", "View Discord Webhook", null, true),
MANAGE_DISCORD_WEBHOOK("Panel", "Manage Discord Webhook", null, true),
VIEW_THEMES("Panel", "View Themes", null, true),
MANAGE_THEMES("Panel", "Manage Themes", null, true),
VIEW_EXTENSIONS("Panel", "View Extensions", null, true),
MANAGE_EXTENSIONS("Panel", "Manage Extensions", null, true),
VIEW_BACKUPS("Extensions", "View Backups", "world-backups", true),
MANAGE_BACKUPS("Extensions", "Manage Backups", "world-backups", true),
VIEW_REPORTS("Extensions", "View Reports", "report-system", true),
MANAGE_REPORTS("Extensions", "Manage Reports", "report-system", true),
VIEW_TICKETS("Extensions", "View Tickets", "ticket-system", true),
MANAGE_TICKETS("Extensions", "Manage Tickets", "ticket-system", true),
VIEW_PLAYER_MANAGEMENT("Extensions", "View Player Management", "player-management", true),
MANAGE_PLAYER_MANAGEMENT("Extensions", "Manage Player Management", "player-management", true),
VIEW_PLAYER_STATS("Extensions", "View Player Stats", "player-stats", true),
VIEW_LUCKPERMS("Extensions", "View LuckPerms Data", "luckperms", true),
VIEW_AIRSTRIKE("Extensions", "View Airstrike", "airstrike", true),
MANAGE_AIRSTRIKE("Extensions", "Launch Airstrike", "airstrike", true),
VIEW_MAINTENANCE("Extensions", "View Maintenance", "maintenance", true),
MANAGE_MAINTENANCE("Extensions", "Manage Maintenance", "maintenance", true),
VIEW_WHITELIST("Extensions", "View Whitelist", "whitelist", true),
MANAGE_WHITELIST("Extensions", "Manage Whitelist", "whitelist", true),
VIEW_ANNOUNCEMENTS("Extensions", "View Announcements", "announcements", true),
MANAGE_ANNOUNCEMENTS("Extensions", "Manage Announcements", "announcements", true);
private final String category;
private final String label;
private final String extensionId;
private final boolean assignable;
PanelPermission(String category, String label, String extensionId, boolean assignable) {
this.category = category;
this.label = label;
this.extensionId = extensionId;
this.assignable = assignable;
}
public String category() {
return category;
}
public String label() {
return label;
}
public String extensionId() {
return extensionId;
}
public boolean assignable() {
return assignable;
}
}
@@ -0,0 +1,41 @@
package de.winniepat.minePanel.users;
import java.util.Set;
public record PanelUser(
long id,
String username,
UserRole role,
Set<PanelPermission> permissions,
long createdAt
) {
public boolean hasPermission(PanelPermission permission) {
if (role == UserRole.OWNER || permissions.contains(permission)) {
return true;
}
if (permission != null) {
String name = permission.name();
if (name.startsWith("VIEW_")) {
String manageName = "MANAGE_" + name.substring("VIEW_".length());
try {
PanelPermission managePermission = PanelPermission.valueOf(manageName);
if (permissions.contains(managePermission)) {
return true;
}
} catch (IllegalArgumentException ignored) {
// ignored
}
}
}
if (permission == PanelPermission.VIEW_DASHBOARD) {
return permissions.contains(PanelPermission.ACCESS_PANEL);
}
if (permission == PanelPermission.VIEW_LOGS) {
return permissions.contains(PanelPermission.VIEW_CONSOLE);
}
return false;
}
}
@@ -0,0 +1,8 @@
package de.winniepat.minePanel.users;
public record PanelUserAuth(
PanelUser user,
String passwordHash
) {
}
@@ -0,0 +1,82 @@
package de.winniepat.minePanel.users;
import java.util.*;
public enum UserRole {
OWNER(EnumSet.allOf(PanelPermission.class)),
ADMIN(EnumSet.of(
PanelPermission.ACCESS_PANEL,
PanelPermission.VIEW_OVERVIEW,
PanelPermission.VIEW_CONSOLE,
PanelPermission.SEND_CONSOLE,
PanelPermission.VIEW_RESOURCES,
PanelPermission.VIEW_PLAYERS,
PanelPermission.MANAGE_PLAYERS,
PanelPermission.VIEW_BANS,
PanelPermission.MANAGE_BANS,
PanelPermission.VIEW_PLUGINS,
PanelPermission.MANAGE_PLUGINS,
PanelPermission.VIEW_USERS,
PanelPermission.VIEW_DISCORD_WEBHOOK,
PanelPermission.MANAGE_DISCORD_WEBHOOK,
PanelPermission.VIEW_THEMES,
PanelPermission.VIEW_EXTENSIONS,
PanelPermission.VIEW_BACKUPS,
PanelPermission.MANAGE_BACKUPS,
PanelPermission.VIEW_REPORTS,
PanelPermission.MANAGE_REPORTS,
PanelPermission.VIEW_TICKETS,
PanelPermission.MANAGE_TICKETS,
PanelPermission.VIEW_PLAYER_MANAGEMENT,
PanelPermission.MANAGE_PLAYER_MANAGEMENT,
PanelPermission.VIEW_PLAYER_STATS,
PanelPermission.VIEW_LUCKPERMS,
PanelPermission.VIEW_AIRSTRIKE,
PanelPermission.MANAGE_AIRSTRIKE,
PanelPermission.VIEW_MAINTENANCE,
PanelPermission.MANAGE_MAINTENANCE,
PanelPermission.VIEW_WHITELIST,
PanelPermission.MANAGE_WHITELIST,
PanelPermission.VIEW_ANNOUNCEMENTS,
PanelPermission.MANAGE_ANNOUNCEMENTS
)),
VIEWER(EnumSet.of(
PanelPermission.ACCESS_PANEL,
PanelPermission.VIEW_OVERVIEW,
PanelPermission.VIEW_CONSOLE,
PanelPermission.VIEW_RESOURCES,
PanelPermission.VIEW_PLAYERS,
PanelPermission.VIEW_BANS,
PanelPermission.VIEW_PLUGINS,
PanelPermission.VIEW_THEMES,
PanelPermission.VIEW_BACKUPS,
PanelPermission.VIEW_REPORTS,
PanelPermission.VIEW_TICKETS,
PanelPermission.VIEW_PLAYER_MANAGEMENT,
PanelPermission.VIEW_PLAYER_STATS,
PanelPermission.VIEW_LUCKPERMS,
PanelPermission.VIEW_AIRSTRIKE,
PanelPermission.VIEW_MAINTENANCE,
PanelPermission.VIEW_WHITELIST,
PanelPermission.VIEW_ANNOUNCEMENTS
));
private final Set<PanelPermission> permissions;
UserRole(Set<PanelPermission> permissions) {
this.permissions = permissions;
}
public boolean hasPermission(PanelPermission permission) {
return permissions.contains(permission);
}
public Set<PanelPermission> defaultPermissions() {
return EnumSet.copyOf(permissions);
}
public static UserRole fromString(String raw) {
return UserRole.valueOf(raw.toUpperCase(Locale.ROOT));
}
}
@@ -0,0 +1,184 @@
package de.winniepat.minePanel.util;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;
import java.lang.reflect.Method;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
public final class ServerSchedulerBridge {
private final JavaPlugin plugin;
private final boolean folia;
private final Object globalRegionScheduler;
private final Method globalRunMethod;
private final Method globalRunDelayedMethod;
private final Method globalRunAtFixedRateMethod;
private final Object asyncScheduler;
private final Method asyncRunNowMethod;
public ServerSchedulerBridge(JavaPlugin plugin) {
this.plugin = plugin;
Object resolvedGlobalScheduler = null;
Method resolvedGlobalRun = null;
Method resolvedGlobalRunDelayed = null;
Method resolvedGlobalRunAtFixedRate = null;
Object resolvedAsyncScheduler = null;
Method resolvedAsyncRunNow = null;
try {
resolvedGlobalScheduler = plugin.getServer().getClass().getMethod("getGlobalRegionScheduler").invoke(plugin.getServer());
resolvedGlobalRun = resolvedGlobalScheduler.getClass().getMethod("run", Plugin.class, Consumer.class);
resolvedGlobalRunDelayed = resolvedGlobalScheduler.getClass().getMethod("runDelayed", Plugin.class, Consumer.class, long.class);
resolvedGlobalRunAtFixedRate = resolvedGlobalScheduler.getClass().getMethod("runAtFixedRate", Plugin.class, Consumer.class, long.class, long.class);
resolvedAsyncScheduler = plugin.getServer().getClass().getMethod("getAsyncScheduler").invoke(plugin.getServer());
resolvedAsyncRunNow = resolvedAsyncScheduler.getClass().getMethod("runNow", Plugin.class, Consumer.class);
} catch (ReflectiveOperationException ignored) {
// Paper/Spigot fallback uses BukkitScheduler APIs.
}
this.globalRegionScheduler = resolvedGlobalScheduler;
this.globalRunMethod = resolvedGlobalRun;
this.globalRunDelayedMethod = resolvedGlobalRunDelayed;
this.globalRunAtFixedRateMethod = resolvedGlobalRunAtFixedRate;
this.asyncScheduler = resolvedAsyncScheduler;
this.asyncRunNowMethod = resolvedAsyncRunNow;
this.folia = this.globalRegionScheduler != null && this.globalRunMethod != null;
}
public boolean isFolia() {
return folia;
}
public CancellableTask runRepeatingGlobal(Runnable task, long initialDelayTicks, long periodTicks) {
if (!folia) {
BukkitTask bukkitTask = plugin.getServer().getScheduler().runTaskTimer(plugin, task, initialDelayTicks, periodTicks);
return bukkitTask::cancel;
}
if (globalRunDelayedMethod == null) {
try {
Object scheduledTask = globalRunAtFixedRateMethod.invoke(
globalRegionScheduler,
plugin,
(Consumer<Object>) ignored -> task.run(),
initialDelayTicks,
periodTicks
);
return () -> cancelFoliaTask(scheduledTask);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("could_not_schedule_global_repeating_task", exception);
}
}
long safeInitialDelay = Math.max(0L, initialDelayTicks);
long safePeriod = Math.max(1L, periodTicks);
AtomicBoolean cancelled = new AtomicBoolean(false);
AtomicReference<Object> scheduledTaskRef = new AtomicReference<>();
class FoliaRepeatingState {
void scheduleNext(long delayTicks) {
if (cancelled.get()) {
return;
}
try {
Object scheduledTask = globalRunDelayedMethod.invoke(
globalRegionScheduler,
plugin,
(Consumer<Object>) ignored -> {
if (cancelled.get()) {
return;
}
task.run();
scheduleNext(safePeriod);
},
Math.max(0L, delayTicks)
);
scheduledTaskRef.set(scheduledTask);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("could_not_schedule_global_repeating_task", exception);
}
}
}
FoliaRepeatingState repeatingState = new FoliaRepeatingState();
// Do not block startup waiting for first tick; on Folia this can deadlock when called during enable.
repeatingState.scheduleNext(safeInitialDelay);
return () -> {
cancelled.set(true);
cancelFoliaTask(scheduledTaskRef.get());
};
}
public void runGlobal(Runnable task) {
if (!folia) {
plugin.getServer().getScheduler().runTask(plugin, task);
return;
}
try {
globalRunMethod.invoke(globalRegionScheduler, plugin, (Consumer<Object>) ignored -> task.run());
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("could_not_schedule_global_task", exception);
}
}
public void runAsync(Runnable task) {
if (!folia || asyncScheduler == null || asyncRunNowMethod == null) {
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, task);
return;
}
try {
asyncRunNowMethod.invoke(asyncScheduler, plugin, (Consumer<Object>) ignored -> task.run());
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("could_not_schedule_async_task", exception);
}
}
public <T> T callGlobal(Callable<T> task, long timeout, TimeUnit timeUnit)
throws InterruptedException, ExecutionException, TimeoutException {
CompletableFuture<T> future = new CompletableFuture<>();
runGlobal(() -> {
try {
future.complete(task.call());
} catch (Throwable throwable) {
future.completeExceptionally(throwable);
}
});
return future.get(timeout, timeUnit);
}
private void cancelFoliaTask(Object task) {
if (task == null) {
return;
}
try {
Method cancelMethod = task.getClass().getMethod("cancel");
cancelMethod.invoke(task);
} catch (ReflectiveOperationException ignored) {
// Best-effort cancellation.
}
}
@FunctionalInterface
public interface CancellableTask {
void cancel();
}
}
@@ -0,0 +1,41 @@
package de.winniepat.minePanel.web;
import de.winniepat.minePanel.persistence.UserRepository;
import java.security.SecureRandom;
import java.util.*;
public final class BootstrapService {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final UserRepository userRepository;
private final String bootstrapToken;
public BootstrapService(UserRepository userRepository, int tokenLength) {
this.userRepository = userRepository;
this.bootstrapToken = generateToken(Math.max(16, tokenLength));
}
public boolean needsBootstrap() {
return userRepository.countUsers() == 0;
}
public Optional<String> getBootstrapToken() {
if (!needsBootstrap()) {
return Optional.empty();
}
return Optional.of(bootstrapToken);
}
public boolean verifyToken(String token) {
return needsBootstrap() && token != null && bootstrapToken.equals(token);
}
private String generateToken(int length) {
byte[] bytes = new byte[length];
SECURE_RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).substring(0, length);
}
}
@@ -0,0 +1,22 @@
package de.winniepat.minePanel.web;
import java.io.*;
import java.nio.charset.StandardCharsets;
public final class ResourceLoader {
private ResourceLoader() {
}
public static String loadUtf8Text(String resourcePath) {
try (InputStream inputStream = ResourceLoader.class.getResourceAsStream(resourcePath)) {
if (inputStream == null) {
throw new IllegalStateException("Resource not found: " + resourcePath);
}
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new IllegalStateException("Could not read resource: " + resourcePath, exception);
}
}
}
@@ -0,0 +1,166 @@
package de.winniepat.minePanel.web;
import de.winniepat.minePanel.MinePanel;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
public final class WebAssetService {
private static final List<String> BUNDLED_WEB_FILES = List.of(
"dashboard-account.html",
"dashboard-announcements.html",
"dashboard-bans.html",
"dashboard-console.html",
"dashboard-discord-webhook.html",
"dashboard-extension-config.html",
"dashboard-extensions.html",
"dashboard-maintenance.html",
"dashboard-overview.html",
"dashboard-players.html",
"dashboard-plugins.html",
"dashboard-reports.html",
"dashboard-resources.html",
"dashboard-tickets.html",
"dashboard-world-backups.html",
"dashboard-whitelist.html",
"dashboard-themes.html",
"dashboard-users.html",
"login.html",
"panel.css",
"setup.html",
"theme.js"
);
private static final String LEGACY_EXTENSIONS_DOWNLOAD_MARKER = "href=\"${downloadUrl}\"";
private static final String CURRENT_EXTENSIONS_INSTALL_MARKER = "data-install-extension";
private static final long VERSION_CACHE_TTL_MILLIS = TimeUnit.SECONDS.toMillis(2);
private final MinePanel plugin;
private final Path webDirectory;
private final Map<String, CachedTextAsset> cachedTextAssets = new ConcurrentHashMap<>();
private volatile long cachedVersion = 0L;
private volatile long cachedVersionExpiresAt = 0L;
public WebAssetService(MinePanel plugin, Path webDirectory) {
this.plugin = plugin;
this.webDirectory = webDirectory;
}
public void ensureSeeded() {
try {
Files.createDirectories(webDirectory);
} catch (IOException exception) {
throw new IllegalStateException("Could not create web directory: " + webDirectory, exception);
}
for (String fileName : BUNDLED_WEB_FILES) {
Path target = webDirectory.resolve(fileName).normalize();
if (!target.startsWith(webDirectory)) {
continue;
}
if (Files.exists(target)) {
migrateLegacyAssets(fileName, target);
continue;
}
plugin.saveResource("web/" + fileName, false);
}
}
private void migrateLegacyAssets(String fileName, Path target) {
if (!"dashboard-extensions.html".equals(fileName)) {
return;
}
try {
String current = Files.readString(target, StandardCharsets.UTF_8);
if (current.contains(CURRENT_EXTENSIONS_INSTALL_MARKER)) {
return;
}
if (!current.contains(LEGACY_EXTENSIONS_DOWNLOAD_MARKER)) {
return;
}
String bundled = ResourceLoader.loadUtf8Text("/web/" + fileName);
Files.writeString(target, bundled, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
plugin.getLogger().info("Updated legacy web template: " + fileName);
} catch (Exception exception) {
plugin.getLogger().warning("Could not migrate legacy web template " + fileName + ": " + exception.getMessage());
}
}
public String readText(String fileName) {
Path target = resolveFile(fileName);
try {
long lastModified = Files.exists(target) ? Files.getLastModifiedTime(target).toMillis() : 0L;
CachedTextAsset cached = cachedTextAssets.get(fileName);
if (cached != null && cached.lastModifiedMillis() == lastModified) {
return cached.content();
}
String content = Files.readString(target, StandardCharsets.UTF_8);
cachedTextAssets.put(fileName, new CachedTextAsset(lastModified, content));
return content;
} catch (IOException exception) {
throw new IllegalStateException("Could not read web asset: " + fileName, exception);
}
}
public long currentVersion() {
long now = System.currentTimeMillis();
if (now < cachedVersionExpiresAt) {
return cachedVersion;
}
long newest = 0L;
for (String fileName : BUNDLED_WEB_FILES) {
Path target = resolveFile(fileName);
if (!Files.exists(target)) {
continue;
}
try {
newest = Math.max(newest, Files.getLastModifiedTime(target).toMillis());
} catch (IOException ignored) {
// Ignore broken files so the rest of the panel can continue serving assets.
}
}
cachedVersion = newest;
cachedVersionExpiresAt = now + VERSION_CACHE_TTL_MILLIS;
return newest;
}
public long lastModifiedMillis(String fileName) {
Path target = resolveFile(fileName);
try {
if (!Files.exists(target)) {
return 0L;
}
return Files.getLastModifiedTime(target).toMillis();
} catch (IOException exception) {
return 0L;
}
}
private Path resolveFile(String fileName) {
Path target = webDirectory.resolve(fileName).normalize();
if (!target.startsWith(webDirectory)) {
throw new IllegalArgumentException("Invalid web asset path: " + fileName);
}
return target;
}
public Path webDirectory() {
return webDirectory;
}
private record CachedTextAsset(long lastModifiedMillis, String content) {
}
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
web:
host: 127.0.0.1
port: 8080
sessionTtlMinutes: 120
security:
bootstrapTokenLength: 32
integrations:
oauth:
stateTtlMinutes: 10
google:
enabled: false
clientId: ""
clientSecret: ""
redirectUri: "http://127.0.0.1:8080/api/oauth/google/callback"
discord:
enabled: false
clientId: ""
clientSecret: ""
redirectUri: "http://127.0.0.1:8080/api/oauth/discord/callback"
github:
token: ""
releaseCacheSeconds: 300
+20
View File
@@ -0,0 +1,20 @@
name: MinePanel
version: '${version}'
main: de.winniepat.minePanel.MinePanel
api-version: '1.21'
authors: [ WinniePatGG ]
website: https://winniepat.de
description: MinePanel with secure auth, user roles, and log history.
folia-supported: true
permissions:
minepanel.report:
description: Allows players to create reports via /report.
default: true
minepanel.ticket:
description: Allows players to create support tickets via /ticket.
default: true
minepanel.chatfilter.bypass:
description: Bypasses MinePanel bad-word chat filter auto moderation.
default: op
@@ -0,0 +1,240 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Account</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
<style>
.oauth-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 12px;
}
.oauth-provider {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
padding: 14px;
}
.oauth-provider h3 {
margin: 0 0 6px;
}
.oauth-meta {
color: var(--text-muted);
font-size: 13px;
margin-bottom: 10px;
}
.oauth-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.oauth-status {
margin-top: 8px;
font-size: 13px;
color: var(--text-muted);
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
<a class="side-link active" href="/dashboard/account">Account</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Account Settings</h1>
<section class="card spaced-top">
<h2>Linked OAuth Accounts</h2>
<p>Link Google or Discord for one-click login on the login page.</p>
<p id="status" class="action-status"></p>
<div id="providers" class="oauth-grid"></div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
function setStatus(message, isError) {
const status = document.getElementById('status');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function providerCard(provider) {
const linked = !!provider.linked;
const enabled = !!provider.enabled;
const configured = !!provider.configured;
const displayName = (provider.displayName || '').trim();
const email = (provider.email || '').trim();
const subtitle = linked
? [displayName || 'Linked account', email].filter(Boolean).join(' - ')
: 'Not linked';
return `
<article class="oauth-provider">
<h3>${provider.label}</h3>
<div class="oauth-meta">${subtitle}</div>
<div class="oauth-actions">
<button data-link="${provider.provider}" ${!enabled || !configured ? 'disabled' : ''}>Link</button>
<button class="secondary" data-unlink="${provider.provider}" ${!linked ? 'disabled' : ''}>Unlink</button>
</div>
<div class="oauth-status">${!enabled ? 'Disabled in config' : (!configured ? 'Missing OAuth credentials' : (linked ? 'Linked' : 'Ready to link'))}</div>
</article>
`;
}
async function loadProviders() {
const data = await api('/api/account/links');
const container = document.getElementById('providers');
const providers = Array.isArray(data.providers) ? data.providers : [];
container.innerHTML = providers.map(providerCard).join('');
container.querySelectorAll('button[data-link]').forEach(button => {
button.addEventListener('click', async () => {
const provider = button.getAttribute('data-link');
try {
const start = await api(`/api/oauth/${provider}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'link' })
});
window.location.href = start.authUrl;
} catch (error) {
setStatus(`Could not start ${provider} linking: ${error.message}`, true);
}
});
});
container.querySelectorAll('button[data-unlink]').forEach(button => {
button.addEventListener('click', async () => {
const provider = button.getAttribute('data-unlink');
if (!confirm(`Unlink ${provider} from your panel account?`)) {
return;
}
try {
await api(`/api/oauth/${provider}/unlink`, { method: 'POST' });
setStatus(`${provider} unlinked.`, false);
await loadProviders();
} catch (error) {
setStatus(`Could not unlink ${provider}: ${error.message}`, true);
}
});
});
}
function showOAuthQueryState() {
const url = new URL(window.location.href);
const oauth = (url.searchParams.get('oauth') || '').trim();
if (!oauth) {
return;
}
if (oauth === 'linked') {
setStatus('OAuth account linked successfully.', false);
} else {
setStatus(`OAuth status: ${oauth}`, true);
}
url.searchParams.delete('oauth');
window.history.replaceState({}, document.title, url.pathname + (url.search ? `?${url.searchParams.toString()}` : ''));
}
document.getElementById('logout').addEventListener('click', async () => {
try {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
} catch (error) {
setStatus(`Logout failed: ${error.message}`, true);
}
});
(async () => {
try {
initSidebarCategories();
await loadMe();
showOAuthQueryState();
await loadProviders();
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,444 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Announcements</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
<style>
.announce-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.announce-stat {
border: 1px solid var(--card-border);
border-radius: 10px;
padding: 12px;
background: var(--panel-bg);
}
.announce-stat-title {
font-size: 12px;
color: var(--muted-text);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 5px;
}
.announce-stat-value {
font-size: 16px;
font-weight: 700;
}
.announce-list {
display: grid;
gap: 8px;
margin-top: 12px;
}
.announce-item {
border: 1px solid var(--table-row-border);
border-radius: 8px;
padding: 10px;
background: var(--panel-bg);
display: grid;
gap: 8px;
}
.announce-item-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.announce-meta {
color: var(--muted-text);
font-size: 12px;
}
.announce-message {
white-space: pre-wrap;
word-break: break-word;
}
.announce-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted-text);
}
.announce-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.announce-message-input {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text);
padding: 10px 11px;
font-size: 14px;
line-height: 1.4;
min-height: 90px;
resize: vertical;
}
.announce-message-input:focus {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
.announce-empty {
color: var(--muted-text);
font-size: 14px;
margin-top: 8px;
}
.announce-placeholders {
margin-top: 8px;
color: var(--muted-text);
font-size: 13px;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
<a class="side-link" href="/dashboard/extensions">Extensions</a>
<a class="side-link" href="/dashboard/extension-config">Extension Config</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Announcements</h1>
<p>Broadcast rotating server announcements to all online players.</p>
<section class="card">
<h2>Status</h2>
<div class="announce-grid">
<div class="announce-stat">
<div class="announce-stat-title">State</div>
<div id="stateValue" class="announce-stat-value">Disabled</div>
</div>
<div class="announce-stat">
<div class="announce-stat-title">Interval</div>
<div id="intervalValue" class="announce-stat-value">300s</div>
</div>
<div class="announce-stat">
<div class="announce-stat-title">Next Broadcast</div>
<div id="nextRunValue" class="announce-stat-value">-</div>
</div>
<div class="announce-stat">
<div class="announce-stat-title">Messages</div>
<div id="countValue" class="announce-stat-value">0</div>
</div>
</div>
</section>
<section class="card spaced-top">
<h2>Configuration</h2>
<label class="checkbox-line"><input id="enabledInput" type="checkbox"> Enable automatic announcements</label>
<label for="intervalInput">Broadcast interval (seconds)</label>
<input id="intervalInput" type="number" min="10" max="86400" value="300">
<div class="announce-toolbar">
<button id="saveConfigBtn">Save Configuration</button>
<button id="sendNowBtn" class="secondary">Send Next Message Now</button>
<button id="refreshBtn" class="secondary">Refresh</button>
</div>
<p id="status" class="action-status"></p>
</section>
<section class="card spaced-top">
<h2>Create Message</h2>
<textarea id="messageInput" class="announce-message-input" maxlength="220" placeholder="Welcome to the server! Use /ticket if you need support."></textarea>
<p class="announce-placeholders">
Add your own prefix directly in the message text (for example <code>[Network]</code> or <code>&lt;gold&gt;[MinePanel]&lt;/gold&gt;</code>).<br>
Placeholders: <code>%player%</code>, <code>%time%</code>, <code>%date%</code>, <code>%datetime%</code>, <code>%online%</code>, <code>%max_players%</code>, <code>%world%</code>, <code>%server%</code>, <code>%tps%</code>.
</p>
<div class="announce-toolbar">
<button id="createBtn">Add Message</button>
</div>
</section>
<section class="card spaced-top">
<h2>Messages</h2>
<div id="messageList" class="announce-list"></div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const nextExpanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', nextExpanded);
toggle.classList.toggle('expanded', nextExpanded);
savedState[category] = nextExpanded;
persistState();
});
});
}
async function api(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
const error = new Error(payload.error || 'Request failed');
error.status = response.status;
throw error;
}
return payload;
}
function setStatus(message, isError) {
const status = document.getElementById('status');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function formatTimestamp(timestamp) {
const millis = Number(timestamp || 0);
if (!Number.isFinite(millis) || millis <= 0) {
return '-';
}
return new Date(millis).toLocaleString();
}
function applyState(state) {
const enabled = state && state.enabled === true;
const interval = Number(state && state.intervalSeconds ? state.intervalSeconds : 300);
const messages = Array.isArray(state && state.messages) ? state.messages : [];
document.getElementById('stateValue').textContent = enabled ? 'Enabled' : 'Disabled';
document.getElementById('intervalValue').textContent = `${interval}s`;
document.getElementById('nextRunValue').textContent = formatTimestamp(state ? state.nextRunAt : 0);
document.getElementById('countValue').textContent = String(messages.length);
document.getElementById('enabledInput').checked = enabled;
document.getElementById('intervalInput').value = String(interval);
renderMessages(messages);
}
function renderMessages(messages) {
const list = document.getElementById('messageList');
list.innerHTML = '';
if (!Array.isArray(messages) || messages.length === 0) {
const empty = document.createElement('p');
empty.className = 'announce-empty';
empty.textContent = 'No announcements configured yet.';
list.appendChild(empty);
return;
}
for (const entry of messages) {
const item = document.createElement('div');
item.className = 'announce-item';
const header = document.createElement('div');
header.className = 'announce-item-header';
const meta = document.createElement('div');
meta.className = 'announce-meta';
meta.textContent = `#${entry.id} · Updated ${formatTimestamp(entry.updatedAt)}`;
const controls = document.createElement('div');
controls.className = 'announce-toolbar';
const toggleLabel = document.createElement('label');
toggleLabel.className = 'announce-toggle';
toggleLabel.innerHTML = `<input type="checkbox" ${entry.enabled ? 'checked' : ''}> Enabled`;
toggleLabel.querySelector('input').addEventListener('change', async (event) => {
try {
await api(`/api/extensions/announcements/messages/${entry.id}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ enabled: !!event.target.checked })
});
await refresh();
} catch (error) {
setStatus(`Could not update message #${entry.id}: ${error.message}`, true);
}
});
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'danger';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', async () => {
try {
await api(`/api/extensions/announcements/messages/${entry.id}/delete`, {
method: 'POST',
credentials: 'same-origin'
});
setStatus(`Deleted announcement #${entry.id}`, false);
await refresh();
} catch (error) {
setStatus(`Could not delete message #${entry.id}: ${error.message}`, true);
}
});
controls.appendChild(toggleLabel);
controls.appendChild(deleteButton);
header.appendChild(meta);
header.appendChild(controls);
const message = document.createElement('div');
message.className = 'announce-message';
message.textContent = entry.message || '';
item.appendChild(header);
item.appendChild(message);
list.appendChild(item);
}
}
async function refresh() {
const state = await api('/api/extensions/announcements', { credentials: 'same-origin', cache: 'no-store' });
applyState(state);
}
async function saveConfig() {
const enabled = document.getElementById('enabledInput').checked;
const interval = Number(document.getElementById('intervalInput').value || 300);
try {
await api('/api/extensions/announcements/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ enabled, intervalSeconds: interval })
});
setStatus('Configuration saved', false);
await refresh();
} catch (error) {
setStatus(`Could not save configuration: ${error.message}`, true);
}
}
async function createMessage() {
const input = document.getElementById('messageInput');
const message = (input.value || '').trim();
if (!message) {
setStatus('Enter a message first.', true);
return;
}
try {
await api('/api/extensions/announcements/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ message })
});
input.value = '';
setStatus('Announcement added', false);
await refresh();
} catch (error) {
setStatus(`Could not create announcement: ${error.message}`, true);
}
}
async function sendNow() {
try {
await api('/api/extensions/announcements/send-now', {
method: 'POST',
credentials: 'same-origin'
});
setStatus('Sent next announcement', false);
await refresh();
} catch (error) {
setStatus(`Could not send announcement: ${error.message}`, true);
}
}
async function logout() {
await api('/api/logout', { method: 'POST', credentials: 'same-origin' });
window.location.href = '/';
}
document.getElementById('saveConfigBtn').addEventListener('click', saveConfig);
document.getElementById('createBtn').addEventListener('click', createMessage);
document.getElementById('sendNowBtn').addEventListener('click', sendNow);
document.getElementById('refreshBtn').addEventListener('click', refresh);
document.getElementById('logout').addEventListener('click', logout);
(async function boot() {
try {
initSidebarCategories();
await loadMe();
await refresh();
} catch (error) {
if (error && error.status === 401) {
window.location.href = '/';
return;
}
setStatus(`Could not load announcements page: ${error.message}`, true);
}
})();
</script>
</body>
</html>
+191
View File
@@ -0,0 +1,191 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Bans</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link active" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Bans</h1>
<p>Current bans: <span id="banCount">0</span></p>
<section class="card spaced-top">
<h2>Ban List</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Player</th>
<th>Status</th>
<th>Expires</th>
<th>Action</th>
</tr>
</thead>
<tbody id="bans"></tbody>
</table>
</div>
<p id="banStatus" class="action-status"></p>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
function setStatus(message, isError) {
const status = document.getElementById('banStatus');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
async function loadBans() {
const players = await api('/api/players');
const allPlayers = Array.isArray(players.allPlayers) ? players.allPlayers : [];
const bans = allPlayers.filter(player => player.banned);
document.getElementById('banCount').textContent = String(bans.length);
const tbody = document.getElementById('bans');
tbody.innerHTML = '';
if (bans.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 4;
cell.textContent = 'No active bans';
row.appendChild(cell);
tbody.appendChild(row);
return;
}
bans.forEach(player => {
const row = document.createElement('tr');
const playerCell = document.createElement('td');
playerCell.textContent = player.username;
row.appendChild(playerCell);
const statusCell = document.createElement('td');
statusCell.textContent = player.online ? 'Online' : 'Offline';
row.appendChild(statusCell);
const expiresCell = document.createElement('td');
expiresCell.textContent = player.banExpiresAt ? new Date(player.banExpiresAt).toLocaleString() : 'Permanent';
row.appendChild(expiresCell);
const actionCell = document.createElement('td');
const button = document.createElement('button');
button.className = 'secondary';
button.textContent = 'Unban';
button.addEventListener('click', async () => {
try {
await api('/api/players/unban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: player.username, uuid: player.uuid })
});
setStatus(`Unbanned ${player.username}`, false);
await loadBans();
} catch (error) {
setStatus(`Failed to unban ${player.username}: ${error.message}`, true);
}
});
actionCell.appendChild(button);
row.appendChild(actionCell);
tbody.appendChild(row);
});
}
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await Promise.all([loadMe(), loadBans()]);
setInterval(() => {
loadBans().catch(() => {});
}, 5000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,181 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Console</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link active" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Console</h1>
<div class="toolbar">
<button id="refresh" class="secondary">Refresh Logs</button>
</div>
<section class="card">
<h2>Panel Logs + Chat + Commands</h2>
<pre id="panelLogs" class="log-rectangle"></pre>
<div class="console-send-row">
<input id="consoleInput" placeholder="Type a console command (example: say hello world)">
<button id="sendConsole">Send</button>
</div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
let latestPanelLogId = 0;
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
async function loadLogs() {
const data = await api('/api/logs?limit=250');
const panelLogsElement = document.getElementById('panelLogs');
panelLogsElement.textContent = [...data.panelLogs]
.reverse()
.map(x => `[${new Date(x.createdAt).toISOString()}] [${x.kind}] [${x.source}] ${x.message}`)
.join('\n');
if (data.panelLogs.length > 0) {
latestPanelLogId = Math.max(latestPanelLogId, ...data.panelLogs.map(x => x.id));
}
panelLogsElement.scrollTop = panelLogsElement.scrollHeight;
}
async function watchForNewLogs() {
const data = await api('/api/logs/latest');
if (data.latestId > latestPanelLogId) {
await loadLogs();
}
}
async function sendConsoleMessage() {
const input = document.getElementById('consoleInput');
const message = input.value.trim();
if (!message) {
return;
}
await api('/api/console/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
input.value = '';
await loadLogs();
}
document.getElementById('refresh').addEventListener('click', async () => {
await loadLogs();
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
document.getElementById('sendConsole').addEventListener('click', async () => {
await sendConsoleMessage();
});
document.getElementById('consoleInput').addEventListener('keydown', async event => {
if (event.key === 'Enter') {
event.preventDefault();
await sendConsoleMessage();
}
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadLogs();
setInterval(() => {
watchForNewLogs().catch(() => {});
}, 1000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,216 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Discord Webhook</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link active" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Discord Webhook</h1>
<p>Configure what MinePanel sends to your Discord channel.</p>
<section class="card spaced-top">
<h2>Webhook Settings</h2>
<div class="integration-form">
<label class="label checkbox-line">
<input id="enabled" type="checkbox">
Enable Discord webhook logging
</label>
<div class="label">Webhook URL</div>
<input id="webhookUrl" placeholder="https://discord.com/api/webhooks/...">
<div class="label">Bot Name</div>
<input id="botName" placeholder="MinePanel">
<label class="label checkbox-line">
<input id="useEmbed" type="checkbox">
Send as embed
</label>
<div class="label">Message Template</div>
<input id="messageTemplate" placeholder="[{timestamp}] [{kind}] [{source}] {message}">
<div class="label">Embed Title Template</div>
<input id="embedTitleTemplate" placeholder="MinePanel {kind}">
<div class="label">Event Filters</div>
<div class="toggle-grid">
<label class="checkbox-line"><input id="logChat" type="checkbox">Chat</label>
<label class="checkbox-line"><input id="logCommands" type="checkbox">Commands</label>
<label class="checkbox-line"><input id="logAuth" type="checkbox">Auth</label>
<label class="checkbox-line"><input id="logAudit" type="checkbox">Audit</label>
<label class="checkbox-line"><input id="logSecurity" type="checkbox">Security (Alt Alerts)</label>
<label class="checkbox-line"><input id="logConsoleResponse" type="checkbox">Console Response</label>
<label class="checkbox-line"><input id="logSystem" type="checkbox">System</label>
</div>
<button id="saveWebhook">Save Webhook Settings</button>
<p id="webhookStatus" class="action-status"></p>
</div>
</section>
<section class="card spaced-top">
<h2>Template Variables</h2>
<p class="template-help">Use <code>{timestamp}</code>, <code>{kind}</code>, <code>{source}</code>, and <code>{message}</code> in your templates.</p>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
function setStatus(message, isError) {
const status = document.getElementById('webhookStatus');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function applyConfig(config) {
document.getElementById('enabled').checked = !!config.enabled;
document.getElementById('webhookUrl').value = config.webhookUrl || '';
document.getElementById('useEmbed').checked = !!config.useEmbed;
document.getElementById('botName').value = config.botName || '';
document.getElementById('messageTemplate').value = config.messageTemplate || '';
document.getElementById('embedTitleTemplate').value = config.embedTitleTemplate || '';
document.getElementById('logChat').checked = !!config.logChat;
document.getElementById('logCommands').checked = !!config.logCommands;
document.getElementById('logAuth').checked = !!config.logAuth;
document.getElementById('logAudit').checked = !!config.logAudit;
document.getElementById('logSecurity').checked = !!config.logSecurity;
document.getElementById('logConsoleResponse').checked = !!config.logConsoleResponse;
document.getElementById('logSystem').checked = !!config.logSystem;
}
function collectConfig() {
return {
enabled: document.getElementById('enabled').checked,
webhookUrl: document.getElementById('webhookUrl').value,
useEmbed: document.getElementById('useEmbed').checked,
botName: document.getElementById('botName').value,
messageTemplate: document.getElementById('messageTemplate').value,
embedTitleTemplate: document.getElementById('embedTitleTemplate').value,
logChat: document.getElementById('logChat').checked,
logCommands: document.getElementById('logCommands').checked,
logAuth: document.getElementById('logAuth').checked,
logAudit: document.getElementById('logAudit').checked,
logSecurity: document.getElementById('logSecurity').checked,
logConsoleResponse: document.getElementById('logConsoleResponse').checked,
logSystem: document.getElementById('logSystem').checked
};
}
async function loadWebhookConfig() {
const config = await api('/api/integrations/discord-webhook');
applyConfig(config);
}
async function saveWebhookConfig() {
const payload = collectConfig();
const response = await api('/api/integrations/discord-webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
applyConfig(response.config);
}
document.getElementById('saveWebhook').addEventListener('click', async () => {
try {
await saveWebhookConfig();
setStatus('Webhook settings saved', false);
} catch (error) {
setStatus(`Save failed: ${error.message}`, true);
}
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await Promise.all([loadMe(), loadWebhookConfig()]);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,543 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Extension Config</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
<style>
.config-grid {
display: grid;
gap: 12px;
grid-template-columns: minmax(220px, 280px) 1fr;
}
.config-list {
display: grid;
gap: 8px;
}
.config-item {
border: 1px solid var(--table-row-border);
background: var(--panel-bg);
border-radius: 8px;
padding: 10px;
cursor: pointer;
color: var(--text);
text-align: left;
width: 100%;
}
.config-item.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.config-item small {
display: block;
color: var(--text-muted);
margin-top: 3px;
}
.config-form {
display: grid;
gap: 10px;
}
.field-label {
margin-bottom: 4px;
color: var(--text-muted);
font-size: 13px;
display: block;
}
.word-list {
min-height: 130px;
width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text);
padding: 10px 11px;
font-size: 14px;
line-height: 1.35;
resize: vertical;
font-family: inherit;
}
.word-list:focus {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
.kv-list {
display: grid;
gap: 8px;
}
.kv-row {
display: grid;
gap: 8px;
grid-template-columns: 1.1fr 1.3fr 130px auto;
align-items: center;
}
.kv-row button {
width: auto;
min-width: 84px;
}
.hint {
margin-top: 10px;
color: var(--text-muted);
font-size: 13px;
}
@media (max-width: 980px) {
.config-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
<a class="side-link" href="/dashboard/extensions">Extensions</a>
<a class="side-link active" href="/dashboard/extension-config">Extension Config</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Extension Config</h1>
<p>Configure extension-specific settings from the panel using form fields.</p>
<section class="card">
<div class="config-grid">
<div>
<h2>Installed Extensions</h2>
<div id="extensionList" class="config-list"></div>
</div>
<div>
<h2 id="editorTitle">Settings</h2>
<div id="configForm" class="config-form"></div>
<div class="toolbar">
<button id="addFieldBtn" class="secondary" style="display:none;">Add Field</button>
<button id="saveBtn">Save Settings</button>
<button id="reloadBtn" class="secondary">Reload</button>
</div>
<p id="status" class="action-status"></p>
</div>
</div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expandedNow = !items.classList.contains('expanded');
items.classList.toggle('expanded', expandedNow);
toggle.classList.toggle('expanded', expandedNow);
savedState[category] = expandedNow;
persistState();
});
});
}
async function api(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
const error = new Error(payload.error || 'Request failed');
error.status = response.status;
throw error;
}
return payload;
}
function setStatus(message, isError) {
const status = document.getElementById('status');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
let extensions = [];
let selectedExtensionId = '';
let selectedSettings = {};
function defaultPlayerManagementSettings() {
return {
badWordFilter: {
enabled: false,
words: [],
autoMuteMinutes: 15,
autoMuteReason: 'Inappropriate language',
cancelMessage: true
}
};
}
function renderExtensionList() {
const list = document.getElementById('extensionList');
list.innerHTML = '';
if (!Array.isArray(extensions) || extensions.length === 0) {
const noData = document.createElement('div');
noData.textContent = 'No installed extensions found.';
list.appendChild(noData);
return;
}
for (const extension of extensions) {
const id = extension.id || '';
const displayName = extension.displayName || id;
const source = extension.source || 'unknown';
const button = document.createElement('button');
button.type = 'button';
button.className = 'config-item' + (id === selectedExtensionId ? ' active' : '');
button.innerHTML = `<strong>${displayName}</strong><small>${id} - ${source}</small>`;
button.addEventListener('click', () => selectExtension(id));
list.appendChild(button);
}
}
function findExtension(extensionId) {
return extensions.find(x => (x.id || '') === extensionId) || null;
}
function clearConfigForm(message) {
const form = document.getElementById('configForm');
form.innerHTML = '';
const placeholder = document.createElement('p');
placeholder.className = 'hint';
placeholder.textContent = message || 'Select an extension to configure.';
form.appendChild(placeholder);
}
function renderPlayerManagementForm(settings) {
const merged = {
...defaultPlayerManagementSettings(),
...(settings && typeof settings === 'object' ? settings : {})
};
const filter = {
...defaultPlayerManagementSettings().badWordFilter,
...(merged.badWordFilter && typeof merged.badWordFilter === 'object' ? merged.badWordFilter : {})
};
const words = Array.isArray(filter.words) ? filter.words.join('\n') : '';
const form = document.getElementById('configForm');
form.innerHTML = `
<label class="checkbox-line"><input id="pmEnabled" type="checkbox"> Enable bad-word filter</label>
<div>
<label class="field-label" for="pmWords">Blocked words (one per line)</label>
<textarea id="pmWords" class="word-list" placeholder="word1\nword2"></textarea>
</div>
<div>
<label class="field-label" for="pmMinutes">Auto mute duration (minutes, 0 = permanent)</label>
<input id="pmMinutes" type="number" min="0" max="43200">
</div>
<div>
<label class="field-label" for="pmReason">Auto mute reason</label>
<input id="pmReason" type="text" maxlength="180">
</div>
<label class="checkbox-line"><input id="pmCancel" type="checkbox"> Cancel blocked message</label>
`;
document.getElementById('pmEnabled').checked = !!filter.enabled;
document.getElementById('pmWords').value = words;
document.getElementById('pmMinutes').value = Number.isFinite(Number(filter.autoMuteMinutes)) ? Number(filter.autoMuteMinutes) : 15;
document.getElementById('pmReason').value = filter.autoMuteReason || 'Inappropriate language';
document.getElementById('pmCancel').checked = filter.cancelMessage !== false;
}
function addGenericFieldRow(key, value, type) {
const list = document.getElementById('kvList');
if (!list) {
return;
}
const row = document.createElement('div');
row.className = 'kv-row';
row.innerHTML = `
<input class="kv-key" type="text" placeholder="setting.key" value="${escapeHtmlAttribute(key || '')}">
<input class="kv-value" type="text" placeholder="value" value="${escapeHtmlAttribute(value == null ? '' : String(value))}">
<select class="kv-type">
<option value="string">Text</option>
<option value="number">Number</option>
<option value="boolean">True/False</option>
</select>
<button class="secondary" type="button">Remove</button>
`;
row.querySelector('.kv-type').value = type || 'string';
row.querySelector('button').addEventListener('click', () => row.remove());
list.appendChild(row);
}
function renderGenericForm(settings) {
const form = document.getElementById('configForm');
form.innerHTML = `
<p class="hint">This extension does not have dedicated fields yet. Use key/value fields.</p>
<div id="kvList" class="kv-list"></div>
`;
const source = settings && typeof settings === 'object' ? settings : {};
const entries = Object.entries(source);
if (entries.length === 0) {
addGenericFieldRow('', '', 'string');
return;
}
for (const [key, value] of entries) {
if (typeof value === 'number') {
addGenericFieldRow(key, value, 'number');
continue;
}
if (typeof value === 'boolean') {
addGenericFieldRow(key, value ? 'true' : 'false', 'boolean');
continue;
}
if (typeof value === 'string') {
addGenericFieldRow(key, value, 'string');
continue;
}
addGenericFieldRow(key, JSON.stringify(value), 'string');
}
}
function renderSettingsForm(extensionId, settings) {
const addFieldBtn = document.getElementById('addFieldBtn');
if (extensionId === 'player-management') {
addFieldBtn.style.display = 'none';
renderPlayerManagementForm(settings);
return;
}
addFieldBtn.style.display = '';
renderGenericForm(settings);
}
async function selectExtension(extensionId) {
selectedExtensionId = extensionId || '';
renderExtensionList();
const editorTitle = document.getElementById('editorTitle');
const extension = findExtension(selectedExtensionId);
if (!extension) {
editorTitle.textContent = 'Settings';
selectedSettings = {};
clearConfigForm('Select an extension to configure.');
return;
}
editorTitle.textContent = `${extension.displayName || extension.id} Settings`;
const payload = await api(`/api/extensions/config/${encodeURIComponent(selectedExtensionId)}`);
selectedSettings = payload && payload.settings && typeof payload.settings === 'object' ? payload.settings : {};
renderSettingsForm(selectedExtensionId, selectedSettings);
}
async function loadExtensions() {
const payload = await api('/api/extensions/config');
extensions = Array.isArray(payload.extensions) ? payload.extensions : [];
if (!selectedExtensionId || !findExtension(selectedExtensionId)) {
selectedExtensionId = extensions.length > 0 ? (extensions[0].id || '') : '';
}
renderExtensionList();
if (selectedExtensionId) {
await selectExtension(selectedExtensionId);
} else {
selectedSettings = {};
clearConfigForm('No installed extensions found.');
document.getElementById('editorTitle').textContent = 'Settings';
}
}
function collectPlayerManagementSettings() {
const wordsRaw = document.getElementById('pmWords').value || '';
const words = wordsRaw
.split(/\r?\n/)
.map(x => x.trim())
.filter(Boolean);
const minutesRaw = Number(document.getElementById('pmMinutes').value || 0);
const minutes = Number.isFinite(minutesRaw) ? Math.max(0, Math.min(43200, Math.floor(minutesRaw))) : 15;
return {
badWordFilter: {
enabled: document.getElementById('pmEnabled').checked,
words,
autoMuteMinutes: minutes,
autoMuteReason: (document.getElementById('pmReason').value || 'Inappropriate language').trim() || 'Inappropriate language',
cancelMessage: document.getElementById('pmCancel').checked
}
};
}
function parseGenericValue(type, value) {
if (type === 'number') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
if (type === 'boolean') {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
return String(value == null ? '' : value);
}
function escapeHtmlAttribute(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function collectGenericSettings() {
const result = {};
document.querySelectorAll('#kvList .kv-row').forEach(row => {
const key = (row.querySelector('.kv-key').value || '').trim();
if (!key) {
return;
}
const type = row.querySelector('.kv-type').value || 'string';
const rawValue = row.querySelector('.kv-value').value || '';
result[key] = parseGenericValue(type, rawValue);
});
return result;
}
function collectSettingsFromForm() {
if (selectedExtensionId === 'player-management') {
return collectPlayerManagementSettings();
}
return collectGenericSettings();
}
async function saveCurrentSettings() {
if (!selectedExtensionId) {
setStatus('Select an extension first.', true);
return;
}
const settings = collectSettingsFromForm();
await api(`/api/extensions/config/${encodeURIComponent(selectedExtensionId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings })
});
selectedSettings = settings;
setStatus(`Saved settings for ${selectedExtensionId}.`, false);
}
document.getElementById('saveBtn').addEventListener('click', async () => {
try {
await saveCurrentSettings();
} catch (error) {
setStatus(error.message || 'Could not save extension settings.', true);
}
});
document.getElementById('reloadBtn').addEventListener('click', async () => {
try {
await loadExtensions();
setStatus('Reloaded extension settings.', false);
} catch (error) {
setStatus(error.message || 'Could not reload extension settings.', true);
}
});
document.getElementById('addFieldBtn').addEventListener('click', () => {
if (!selectedExtensionId) {
setStatus('Select an extension first.', true);
return;
}
if (selectedExtensionId === 'player-management') {
setStatus('Player management uses dedicated fields.', true);
return;
}
addGenericFieldRow('', '', 'string');
setStatus('Added new field row.', false);
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadExtensions();
} catch (error) {
if (error && error.status === 401) {
window.location.href = '/';
return;
}
setStatus(error && error.message ? error.message : 'Could not load extension config page.', true);
}
})();
</script>
</body>
</html>
@@ -0,0 +1,417 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Extensions</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
<style>
.ext-link {
color: var(--accent);
text-decoration: none;
font-weight: 600;
border-bottom: 1px solid transparent;
transition: color 0.15s ease, border-color 0.15s ease;
}
.ext-link:hover {
color: #9ec0ff;
border-bottom-color: #9ec0ff;
}
.ext-download {
display: inline-block;
padding: 6px 10px;
border-radius: 7px;
border: 1px solid var(--button-border);
background: var(--button-bg);
color: #ffffff;
text-decoration: none;
font-weight: 600;
font-size: 13px;
line-height: 1;
transition: background 0.15s ease;
}
.ext-download:hover {
background: var(--button-hover-bg);
}
.ext-status {
display: inline-block;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--table-row-border);
font-size: 12px;
font-weight: 700;
}
.ext-status-up {
color: #8af0b4;
background: rgba(46, 204, 113, 0.18);
border-color: rgba(46, 204, 113, 0.5);
}
.ext-status-outdated {
color: #ffe08b;
background: rgba(241, 196, 15, 0.18);
border-color: rgba(241, 196, 15, 0.5);
}
.ext-status-unknown {
color: #c7d1ea;
background: rgba(127, 140, 170, 0.18);
border-color: rgba(127, 140, 170, 0.5);
}
.ext-status-restart {
color: #9ec0ff;
background: rgba(81, 125, 255, 0.2);
border-color: rgba(81, 125, 255, 0.45);
}
.ext-status-installed {
color: #8af0b4;
background: rgba(46, 204, 113, 0.18);
border-color: rgba(46, 204, 113, 0.5);
}
.ext-warning {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(241, 196, 15, 0.45);
background: rgba(241, 196, 15, 0.14);
color: #ffe08b;
font-size: 13px;
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
<a class="side-link active" href="/dashboard/extensions">Extensions</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Extensions</h1>
<p>Shows loaded extensions and extension downloads from the latest GitHub release/pre-release.</p>
<div id="extensionsWarning" class="ext-warning" style="display:none;"></div>
<div id="extensionsInstallMessage" class="ext-warning" style="display:none;"></div>
<div class="toolbar">
<select id="releaseChannel" style="width:auto;min-width:180px;">
<option value="release">Release</option>
<option value="prerelease">Pre-release</option>
</select>
<button id="reloadExtensions" class="secondary">Reload Extensions</button>
<button id="refresh" class="secondary">Refresh</button>
</div>
<section class="card">
<h2>Installed Extensions (<span id="installedCount">0</span>)</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Source</th>
<th>Status</th>
</tr>
</thead>
<tbody id="installedBody"></tbody>
</table>
</section>
<section class="card spaced-top">
<h2>Available Extensions on GitHub (<span id="availableExtensionCount">0</span>)</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Release</th>
<th>Download</th>
</tr>
</thead>
<tbody id="availableExtensionsBody"></tbody>
</table>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const nextExpanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', nextExpanded);
toggle.classList.toggle('expanded', nextExpanded);
savedState[category] = nextExpanded;
persistState();
});
});
}
async function api(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Request failed');
}
return payload;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function renderInstalled(installed) {
const body = document.getElementById('installedBody');
body.innerHTML = '';
if (!Array.isArray(installed) || installed.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4">No extensions loaded</td>';
body.appendChild(row);
return;
}
for (const extension of installed) {
const row = document.createElement('tr');
const status = extension.status || 'unknown';
const statusText = extension.statusText || 'Unknown';
const statusClass = status === 'outdated'
? 'ext-status ext-status-outdated'
: (status === 'up_to_date' ? 'ext-status ext-status-up' : 'ext-status ext-status-unknown');
const statusMarkup = `<span class="${statusClass}">${statusText}</span>`;
row.innerHTML = `
<td>${extension.id}</td>
<td>${extension.displayName}</td>
<td>${extension.source}</td>
<td>${statusMarkup}</td>
`;
body.appendChild(row);
}
}
function installStateBadge(state) {
if (state === 'restart_required') {
return '<span class="ext-status ext-status-restart">Restart required</span>';
}
if (state === 'installed') {
return '<span class="ext-status ext-status-installed">Installed</span>';
}
return '';
}
function renderAvailableExtensions(availableExtensions) {
const body = document.getElementById('availableExtensionsBody');
body.innerHTML = '';
if (!Array.isArray(availableExtensions) || availableExtensions.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4">No MinePanel extension assets found on GitHub releases</td>';
body.appendChild(row);
return;
}
for (const extension of availableExtensions) {
const name = extension.name || extension.id || 'Unknown';
const description = extension.description || '-';
const release = extension.release || '-';
const projectUrl = extension.projectUrl || '#';
const extensionId = extension.extensionId || '';
const fileName = extension.id || '';
const installState = extension.installState || 'not_installed';
const prereleaseSuffix = extension.prerelease ? ' (pre-release)' : '';
const actionMarkup = installState === 'restart_required'
? '<span class="ext-status ext-status-restart">Restart required</span>'
: (installState === 'installed'
? '<span class="ext-status ext-status-installed">Installed</span>'
: `<button class="ext-download" data-install-extension="${extensionId}" data-install-file="${fileName}">Install</button>`);
const row = document.createElement('tr');
row.innerHTML = `
<td><a class="ext-link" href="${projectUrl}" target="_blank" rel="noopener noreferrer">${name}</a></td>
<td>${description}</td>
<td>${release}${prereleaseSuffix}</td>
<td>${actionMarkup}</td>
`;
body.appendChild(row);
}
body.querySelectorAll('button[data-install-extension]').forEach(button => {
button.addEventListener('click', async () => {
const channel = document.getElementById('releaseChannel').value || 'release';
const selectedExtensionId = button.dataset.installExtension || '';
const selectedFile = button.dataset.installFile || '';
await installExtension(channel, selectedExtensionId, selectedFile, button);
});
});
}
function showInstallMessage(message, isError) {
const banner = document.getElementById('extensionsInstallMessage');
banner.textContent = message || '';
banner.style.display = message ? 'block' : 'none';
if (isError) {
banner.style.borderColor = 'rgba(231, 76, 60, 0.45)';
banner.style.background = 'rgba(231, 76, 60, 0.16)';
banner.style.color = '#ffc7c1';
return;
}
banner.style.borderColor = 'rgba(81, 125, 255, 0.45)';
banner.style.background = 'rgba(81, 125, 255, 0.16)';
banner.style.color = '#bfd1ff';
}
async function installExtension(channel, extensionId, fileName, button) {
if (!extensionId || !fileName) {
showInstallMessage('Invalid extension selection.', true);
return;
}
const previousText = button.textContent;
button.disabled = true;
button.textContent = 'Installing...';
showInstallMessage('', false);
try {
const response = await api('/api/extensions/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, extensionId, fileName })
});
const text = response.message || 'Downloaded extension. Restart required.';
showInstallMessage(text, false);
await loadExtensionStatus();
} catch (error) {
showInstallMessage(error.message || 'Could not install extension.', true);
button.disabled = false;
button.textContent = previousText;
}
}
async function reloadExtensions(button) {
const previousText = button.textContent;
button.disabled = true;
button.textContent = 'Reloading...';
showInstallMessage('', false);
try {
const payload = await api('/api/extensions/reload', { method: 'POST' });
const warningText = Array.isArray(payload.warnings) && payload.warnings.length > 0
? ` Warnings: ${payload.warnings.join(', ')}`
: '';
showInstallMessage((payload.message || 'Extensions reloaded.') + warningText, false);
await loadExtensionStatus();
} catch (error) {
showInstallMessage(error.message || 'Could not reload extensions.', true);
} finally {
button.disabled = false;
button.textContent = previousText;
}
}
async function loadExtensionStatus() {
const channel = document.getElementById('releaseChannel').value || 'release';
const status = await api(`/api/extensions/status?channel=${encodeURIComponent(channel)}`);
if (status.channel) {
document.getElementById('releaseChannel').value = status.channel;
}
const warning = document.getElementById('extensionsWarning');
if (status.availableExtensionsWarning) {
warning.textContent = status.availableExtensionsWarning;
warning.style.display = 'block';
} else {
warning.textContent = '';
warning.style.display = 'none';
}
document.getElementById('installedCount').textContent = String(status.installedCount || 0);
document.getElementById('availableExtensionCount').textContent = String(status.availableExtensionCount || 0);
renderInstalled(status.installed);
renderAvailableExtensions(status.availableExtensions);
}
document.getElementById('releaseChannel').addEventListener('change', async () => {
await loadExtensionStatus();
});
document.getElementById('refresh').addEventListener('click', async () => {
showInstallMessage('', false);
await loadExtensionStatus();
});
document.getElementById('reloadExtensions').addEventListener('click', async () => {
const button = document.getElementById('reloadExtensions');
await reloadExtensions(button);
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadExtensionStatus();
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,349 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Maintenance</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
<style>
.maintenance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.maintenance-stat {
border: 1px solid var(--card-border);
background: var(--panel-bg);
border-radius: 10px;
padding: 12px;
}
.maintenance-stat-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted-text);
margin-bottom: 6px;
}
.maintenance-stat-value {
font-size: 16px;
font-weight: 700;
}
.maintenance-online {
color: #8af0b4;
}
.maintenance-offline {
color: #ff9ca1;
}
.maintenance-reason {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text);
padding: 10px 11px;
font-size: 14px;
font-family: inherit;
line-height: 1.4;
min-height: 84px;
resize: vertical;
}
.maintenance-reason:focus {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
.maintenance-motd {
margin-top: 8px;
}
.maintenance-note {
margin-top: 10px;
font-size: 13px;
color: var(--muted-text);
}
.maintenance-message {
margin-top: 12px;
padding: 9px 11px;
border-radius: 8px;
border: 1px solid var(--table-row-border);
background: var(--panel-bg);
display: none;
}
.maintenance-message.error {
border-color: rgba(231, 76, 60, 0.45);
background: rgba(231, 76, 60, 0.14);
color: #ffc7c1;
}
.maintenance-message.success {
border-color: rgba(46, 204, 113, 0.45);
background: rgba(46, 204, 113, 0.14);
color: #b9f8cf;
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
<a class="side-link" href="/dashboard/extensions">Extensions</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Maintenance</h1>
<p>Enable maintenance mode, block non-staff joins, and remove non-staff players currently online.</p>
<section class="card">
<h2>Status</h2>
<div class="maintenance-grid">
<div class="maintenance-stat">
<div class="maintenance-stat-title">State</div>
<div id="stateValue" class="maintenance-stat-value">Unknown</div>
</div>
<div class="maintenance-stat">
<div class="maintenance-stat-title">Affected Players</div>
<div id="affectedValue" class="maintenance-stat-value">0</div>
</div>
<div class="maintenance-stat">
<div class="maintenance-stat-title">Last Changed</div>
<div id="changedValue" class="maintenance-stat-value">-</div>
</div>
<div class="maintenance-stat">
<div class="maintenance-stat-title">Bypass Permission</div>
<div id="bypassValue" class="maintenance-stat-value">minepanel.maintenance.bypass</div>
</div>
</div>
<p class="maintenance-note">Operators and players with bypass permission can still join.</p>
</section>
<section class="card spaced-top">
<h2>Controls</h2>
<label for="reasonInput">Maintenance reason</label>
<textarea id="reasonInput" class="maintenance-reason" placeholder="Server maintenance in progress"></textarea>
<label class="maintenance-motd" for="motdInput">Maintenance MOTD</label>
<input id="motdInput" type="text" placeholder="Maintenance mode is active">
<div class="toolbar">
<button id="enableKickBtn">Enable + Kick Non-Staff</button>
<button id="enableBtn" class="secondary">Enable Only</button>
<button id="disableBtn" class="danger">Disable</button>
<button id="kickBtn" class="secondary">Kick Non-Staff Now</button>
<button id="refreshBtn" class="secondary">Refresh</button>
</div>
<div id="message" class="maintenance-message"></div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const nextExpanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', nextExpanded);
toggle.classList.toggle('expanded', nextExpanded);
savedState[category] = nextExpanded;
persistState();
});
});
}
async function api(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Request failed');
}
return payload;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function setMessage(message, kind) {
const messageElement = document.getElementById('message');
messageElement.textContent = message || '';
messageElement.className = 'maintenance-message';
if (!message) {
messageElement.style.display = 'none';
return;
}
if (kind === 'error') {
messageElement.classList.add('error');
} else {
messageElement.classList.add('success');
}
messageElement.style.display = 'block';
}
function applyStatus(status) {
const stateElement = document.getElementById('stateValue');
const enabled = status.enabled === true;
stateElement.textContent = enabled ? 'Enabled' : 'Disabled';
stateElement.classList.toggle('maintenance-online', enabled);
stateElement.classList.toggle('maintenance-offline', !enabled);
document.getElementById('affectedValue').textContent = String(status.affectedOnlinePlayers || 0);
document.getElementById('bypassValue').textContent = status.bypassPermission || 'minepanel.maintenance.bypass';
const changedBy = status.changedBy || 'SYSTEM';
const changedAt = Number(status.changedAt || 0);
const changedText = changedAt > 0 ? `${new Date(changedAt).toLocaleString()} by ${changedBy}` : `- by ${changedBy}`;
document.getElementById('changedValue').textContent = changedText;
const reasonInput = document.getElementById('reasonInput');
if (typeof status.reason === 'string' && status.reason.length > 0) {
reasonInput.value = status.reason;
}
const motdInput = document.getElementById('motdInput');
if (typeof status.motd === 'string' && status.motd.length > 0) {
motdInput.value = status.motd;
}
}
async function refreshStatus() {
const status = await api('/api/extensions/maintenance/status');
applyStatus(status);
}
async function toggleMaintenance(enable, kickNonStaff) {
const reason = document.getElementById('reasonInput').value.trim();
const motd = document.getElementById('motdInput').value.trim();
const endpoint = enable ? '/api/extensions/maintenance/enable' : '/api/extensions/maintenance/disable';
const body = enable ? { reason, motd, kickNonStaff: kickNonStaff === true } : {};
const result = await api(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
applyStatus(result);
const kickedPart = Number(result.kicked || 0) > 0 ? ` Kicked ${result.kicked} player(s).` : '';
setMessage(enable ? `Maintenance enabled.${kickedPart}` : 'Maintenance disabled.', 'success');
}
async function kickNonStaffNow() {
const result = await api('/api/extensions/maintenance/kick-nonstaff', { method: 'POST' });
applyStatus(result);
setMessage(`Kicked ${result.kicked || 0} player(s).`, 'success');
}
document.getElementById('enableKickBtn').addEventListener('click', async () => {
try {
await toggleMaintenance(true, true);
} catch (error) {
setMessage(error.message || 'Could not enable maintenance.', 'error');
}
});
document.getElementById('enableBtn').addEventListener('click', async () => {
try {
await toggleMaintenance(true, false);
} catch (error) {
setMessage(error.message || 'Could not enable maintenance.', 'error');
}
});
document.getElementById('disableBtn').addEventListener('click', async () => {
try {
await toggleMaintenance(false, false);
} catch (error) {
setMessage(error.message || 'Could not disable maintenance.', 'error');
}
});
document.getElementById('kickBtn').addEventListener('click', async () => {
try {
await kickNonStaffNow();
} catch (error) {
setMessage(error.message || 'Could not kick players.', 'error');
}
});
document.getElementById('refreshBtn').addEventListener('click', async () => {
try {
await refreshStatus();
setMessage('', 'success');
} catch (error) {
setMessage(error.message || 'Could not refresh status.', 'error');
}
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await refreshStatus();
setInterval(() => {
refreshStatus().catch(() => {});
}, 3000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,523 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Overview</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link active" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Overview</h1>
<section class="overview-top-grid spaced-top">
<div class="card stat-card">
<div class="stat-label">TPS <span id="tpsStatus" class="metric-status metric-unknown">Unavailable</span></div>
<div id="statTps" class="stat-value">-</div>
</div>
<div class="card stat-card">
<div class="stat-label">Memory Usage</div>
<div id="statMemory" class="stat-value">0 MB / 0 MB</div>
</div>
<div class="card stat-card">
<div class="stat-label">CPU Usage</div>
<div id="statCpu" class="stat-value">-</div>
</div>
</section>
<section class="card spaced-top">
<h2>TPS Over Time (Last Hour)</h2>
<div class="chart-wrapper chart-tall">
<canvas id="tpsChart"></canvas>
</div>
</section>
<section class="overview-bottom-grid spaced-top">
<div class="card">
<h2>Memory Over Time</h2>
<div class="chart-wrapper">
<canvas id="memoryChart"></canvas>
</div>
</div>
<div class="card">
<h2>CPU Over Time</h2>
<div class="chart-wrapper">
<canvas id="cpuChart"></canvas>
</div>
</div>
</section>
<section class="overview-bottom-grid spaced-top">
<div class="card">
<h2>Join Heatmap (Day x Hour)</h2>
<div class="chart-wrapper">
<canvas id="joinHeatmapChart"></canvas>
</div>
</div>
<div class="card">
<h2>Leave Heatmap (Day x Hour)</h2>
<div class="chart-wrapper">
<canvas id="leaveHeatmapChart"></canvas>
</div>
</div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
let latestHistory = { tps: [], memory: [], cpu: [] };
let latestHeatmaps = { join: [], leave: [], maxJoin: 0, maxLeave: 0 };
const heatmapRenderMeta = {};
const heatmapTooltip = createHeatmapTooltip();
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
async function loadOverview() {
const metrics = await api('/api/overview/metrics');
const current = metrics.current || {};
latestHistory = metrics.history || { tps: [], memory: [], cpu: [] };
latestHeatmaps = {
join: Array.isArray(metrics.joinHeatmap) ? metrics.joinHeatmap : [],
leave: Array.isArray(metrics.leaveHeatmap) ? metrics.leaveHeatmap : [],
maxJoin: Number(metrics.heatmapMaxJoin) || 0,
maxLeave: Number(metrics.heatmapMaxLeave) || 0
};
updateTopMetrics(current);
drawAllCharts();
}
function updateTopMetrics(current) {
const tps = Number(current.tps);
const memoryUsedMb = Number(current.memoryUsedMb);
const memoryMaxMb = Number(current.memoryMaxMb);
const cpuPercent = Number(current.cpuPercent);
if (Number.isFinite(tps) && tps >= 0) {
document.getElementById('statTps').textContent = tps.toFixed(2);
} else {
document.getElementById('statTps').textContent = '-';
}
document.getElementById('statMemory').textContent = Number.isFinite(memoryUsedMb) && Number.isFinite(memoryMaxMb) && memoryMaxMb > 0
? `${Math.max(0, memoryUsedMb).toFixed(0)} MB / ${Math.max(0, memoryMaxMb).toFixed(0)} MB`
: 'Unavailable';
document.getElementById('statCpu').textContent = Number.isFinite(cpuPercent) && cpuPercent >= 0
? `${cpuPercent.toFixed(1)}%`
: 'Unavailable';
const status = resolveTpsStatus(tps);
const statusElement = document.getElementById('tpsStatus');
statusElement.textContent = status.label;
statusElement.className = `metric-status ${status.cssClass}`;
}
function resolveTpsStatus(tps) {
if (!Number.isFinite(tps) || tps < 0) {
return { label: 'Unavailable', cssClass: 'metric-unknown' };
}
if (tps >= 18.0) {
return { label: 'Good', cssClass: 'metric-good' };
}
if (tps >= 15.0) {
return { label: 'Medium', cssClass: 'metric-medium' };
}
return { label: 'Bad', cssClass: 'metric-bad' };
}
function themeColor(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function drawAllCharts() {
drawSeriesChart('tpsChart', latestHistory.tps || [], {
minY: 0,
maxY: 20,
color: '#58d68d',
fill: 'rgba(88, 214, 141, 0.18)'
});
drawSeriesChart('memoryChart', latestHistory.memory || [], {
minY: 0,
maxY: 100,
color: '#5da5ff',
fill: 'rgba(93, 165, 255, 0.18)'
});
drawSeriesChart('cpuChart', latestHistory.cpu || [], {
minY: 0,
maxY: 100,
color: '#f6c85f',
fill: 'rgba(246, 200, 95, 0.18)'
});
drawHeatmap('joinHeatmapChart', latestHeatmaps.join, latestHeatmaps.maxJoin, '#58d68d');
drawHeatmap('leaveHeatmapChart', latestHeatmaps.leave, latestHeatmaps.maxLeave, '#ff8a8a');
}
function drawHeatmap(canvasId, cells, maxCount, accentColor) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
canvas.width = Math.max(1, Math.floor(width * dpr));
canvas.height = Math.max(1, Math.floor(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const chartBackground = themeColor('--chart-bg', '#0b1427');
const chartLabel = themeColor('--chart-label', '#8ea6d2');
const chartGrid = themeColor('--chart-grid', 'rgba(120, 150, 210, 0.22)');
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = chartBackground;
ctx.fillRect(0, 0, width, height);
const padding = { left: 48, right: 10, top: 10, bottom: 22 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
if (chartWidth <= 0 || chartHeight <= 0) {
return;
}
const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const cellWidth = chartWidth / 24;
const cellHeight = chartHeight / 7;
const maxValue = Math.max(1, Number(maxCount) || 0);
const byKey = new Map();
for (const cell of Array.isArray(cells) ? cells : []) {
const day = Number(cell.day);
const hour = Number(cell.hour);
const count = Number(cell.count);
if (!Number.isFinite(day) || !Number.isFinite(hour) || !Number.isFinite(count)) {
continue;
}
byKey.set(`${day}:${hour}`, Math.max(0, Math.floor(count)));
}
for (let day = 0; day < 7; day += 1) {
for (let hour = 0; hour < 24; hour += 1) {
const count = byKey.get(`${day}:${hour}`) || 0;
const normalized = count <= 0 ? 0 : Math.pow(Math.min(1, count / maxValue), 0.6);
const alpha = count <= 0 ? 0.03 : Math.min(1, 0.1 + normalized * 0.9);
const x = padding.left + hour * cellWidth;
const y = padding.top + day * cellHeight;
ctx.fillStyle = toRgba(accentColor, alpha);
ctx.fillRect(x + 1, y + 1, Math.max(1, cellWidth - 2), Math.max(1, cellHeight - 2));
}
}
ctx.strokeStyle = chartGrid;
ctx.lineWidth = 1;
for (let day = 0; day <= 7; day += 1) {
const y = padding.top + day * cellHeight;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(padding.left + chartWidth, y);
ctx.stroke();
}
for (let hour = 0; hour <= 24; hour += 1) {
const x = padding.left + hour * cellWidth;
ctx.beginPath();
ctx.moveTo(x, padding.top);
ctx.lineTo(x, padding.top + chartHeight);
ctx.stroke();
}
ctx.fillStyle = chartLabel;
ctx.font = '11px Segoe UI';
ctx.textBaseline = 'middle';
ctx.textAlign = 'right';
for (let day = 0; day < 7; day += 1) {
const y = padding.top + (day + 0.5) * cellHeight;
ctx.fillText(dayLabels[day], padding.left - 6, y);
}
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (let hour = 0; hour < 24; hour += 6) {
const x = padding.left + (hour + 0.5) * cellWidth;
ctx.fillText(`${hour}:00`, x, padding.top + chartHeight + 4);
}
heatmapRenderMeta[canvasId] = {
padding,
chartWidth,
chartHeight,
cellWidth,
cellHeight,
byKey,
dayLabels
};
}
function createHeatmapTooltip() {
const tooltip = document.createElement('div');
tooltip.style.position = 'fixed';
tooltip.style.zIndex = '1000';
tooltip.style.padding = '6px 8px';
tooltip.style.border = '1px solid var(--chart-border, #213457)';
tooltip.style.background = 'var(--surface, #111a2e)';
tooltip.style.color = 'var(--text, #e5ebf8)';
tooltip.style.borderRadius = '6px';
tooltip.style.font = '12px Segoe UI, sans-serif';
tooltip.style.pointerEvents = 'none';
tooltip.style.display = 'none';
document.body.appendChild(tooltip);
return tooltip;
}
function setupHeatmapHover(canvasId, mode) {
const canvas = document.getElementById(canvasId);
if (!canvas || canvas.dataset.hoverBound === '1') {
return;
}
canvas.dataset.hoverBound = '1';
canvas.addEventListener('mousemove', event => {
const meta = heatmapRenderMeta[canvasId];
if (!meta) {
heatmapTooltip.style.display = 'none';
return;
}
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const insideX = x >= meta.padding.left && x <= (meta.padding.left + meta.chartWidth);
const insideY = y >= meta.padding.top && y <= (meta.padding.top + meta.chartHeight);
if (!insideX || !insideY) {
heatmapTooltip.style.display = 'none';
return;
}
const hour = Math.max(0, Math.min(23, Math.floor((x - meta.padding.left) / meta.cellWidth)));
const day = Math.max(0, Math.min(6, Math.floor((y - meta.padding.top) / meta.cellHeight)));
const count = meta.byKey.get(`${day}:${hour}`) || 0;
const verb = mode === 'join' ? 'joined' : 'left';
const dayLabel = meta.dayLabels[day] || 'Unknown';
heatmapTooltip.textContent = `${dayLabel} ${hour}:00-${hour + 1}:00: ${count} ${verb}`;
heatmapTooltip.style.left = `${event.clientX + 12}px`;
heatmapTooltip.style.top = `${event.clientY + 12}px`;
heatmapTooltip.style.display = 'block';
});
canvas.addEventListener('mouseleave', () => {
heatmapTooltip.style.display = 'none';
});
}
function toRgba(hex, alpha) {
const normalized = (hex || '').trim();
const parsed = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(normalized);
if (!parsed) {
return `rgba(88, 140, 255, ${alpha})`;
}
const r = parseInt(parsed[1], 16);
const g = parseInt(parsed[2], 16);
const b = parseInt(parsed[3], 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function drawSeriesChart(canvasId, points, options) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
canvas.width = Math.max(1, Math.floor(width * dpr));
canvas.height = Math.max(1, Math.floor(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const chartBackground = themeColor('--chart-bg', '#0b1427');
const chartGrid = themeColor('--chart-grid', 'rgba(120, 150, 210, 0.22)');
const chartLabel = themeColor('--chart-label', '#8ea6d2');
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = chartBackground;
ctx.fillRect(0, 0, width, height);
const padding = { left: 40, right: 16, top: 12, bottom: 24 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
if (chartWidth <= 0 || chartHeight <= 0) {
return;
}
const minY = options.minY;
const maxY = options.maxY;
const safePoints = (Array.isArray(points) ? points : [])
.map(point => ({
timestamp: Number(point.timestamp),
value: Number(point.value)
}))
.filter(point => Number.isFinite(point.timestamp) && Number.isFinite(point.value) && point.value >= 0);
const now = Date.now();
const minX = now - (60 * 60 * 1000);
const maxX = now;
ctx.strokeStyle = chartGrid;
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i += 1) {
const y = padding.top + (chartHeight * i / 4);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
const labelValue = maxY - ((maxY - minY) * i / 4);
ctx.fillStyle = chartLabel;
ctx.font = '11px Segoe UI';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(labelValue.toFixed(0), padding.left - 6, y);
}
if (safePoints.length === 0) {
ctx.fillStyle = chartLabel;
ctx.font = '13px Segoe UI';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('No data yet', width / 2, height / 2);
return;
}
const toX = timestamp => padding.left + ((timestamp - minX) / (maxX - minX)) * chartWidth;
const toY = value => padding.top + ((maxY - value) / (maxY - minY)) * chartHeight;
ctx.beginPath();
safePoints.forEach((point, index) => {
const x = toX(Math.max(minX, Math.min(maxX, point.timestamp)));
const y = toY(Math.max(minY, Math.min(maxY, point.value)));
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.lineTo(toX(Math.max(minX, Math.min(maxX, safePoints[safePoints.length - 1].timestamp))), padding.top + chartHeight);
ctx.lineTo(toX(Math.max(minX, Math.min(maxX, safePoints[0].timestamp))), padding.top + chartHeight);
ctx.closePath();
ctx.fillStyle = options.fill;
ctx.fill();
ctx.beginPath();
safePoints.forEach((point, index) => {
const x = toX(Math.max(minX, Math.min(maxX, point.timestamp)));
const y = toY(Math.max(minY, Math.min(maxY, point.value)));
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.strokeStyle = options.color;
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = chartLabel;
ctx.font = '11px Segoe UI';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('1h ago', padding.left, height - 18);
ctx.textAlign = 'right';
ctx.fillText('now', width - padding.right, height - 18);
}
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
setupHeatmapHover('joinHeatmapChart', 'join');
setupHeatmapHover('leaveHeatmapChart', 'leave');
await Promise.all([loadMe(), loadOverview()]);
setInterval(() => {
loadOverview().catch(() => {});
}, 5000);
window.addEventListener('resize', () => {
drawAllCharts();
});
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,710 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Players</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link active" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Players</h1>
<p>All players that ever joined: <span id="playerCount">0</span></p>
<section class="players-layout spaced-top">
<div class="card">
<div class="card-head">
<h2>Player List</h2>
<input id="playerSearch" class="player-search" placeholder="Search player">
</div>
<ul id="playerDirectory" class="player-directory"></ul>
</div>
<div class="card profile-card">
<h2>Player Profile</h2>
<div id="profileEmpty" class="player-empty">Select a player from the list.</div>
<div id="profileContent" class="profile-pane" style="display:none;">
<div class="profile-header">
<img id="profileAvatar" class="profile-avatar" alt="Player skin face">
<div>
<div class="profile-name-row">
<span id="profileName" class="profile-name"></span>
<span id="profileStatusDot" class="status-dot"></span>
<span id="profileStatusText" class="profile-status"></span>
</div>
<div id="profileUuid" class="profile-uuid"></div>
<div id="profileMuteState" class="profile-uuid" style="margin-top:6px;">Mute: -</div>
</div>
</div>
<div class="profile-grid">
<div class="profile-field"><span>First joined</span><strong id="profileFirstJoined">-</strong></div>
<div class="profile-field"><span>Last seen</span><strong id="profileLastSeen">-</strong></div>
<div class="profile-field"><span>Total playtime</span><strong id="profilePlaytime">-</strong></div>
<div class="profile-field"><span>Total sessions</span><strong id="profileSessions">-</strong></div>
<div class="profile-field"><span>IP Address</span><strong id="profileIp">-</strong></div>
<div class="profile-field"><span>Country</span><strong id="profileCountry">-</strong></div>
</div>
<div id="playerStatsSection" class="card spaced-top" style="display:none;padding:12px;">
<h3 style="margin:0 0 8px;">Player Stats</h3>
<div class="profile-grid">
<div class="profile-field"><span>Kills</span><strong id="statsKills">-</strong></div>
<div class="profile-field"><span>Deaths</span><strong id="statsDeaths">-</strong></div>
<div class="profile-field"><span>Money</span><strong id="statsMoney">-</strong></div>
<div class="profile-field"><span>Economy Provider</span><strong id="statsEconomyProvider">-</strong></div>
</div>
</div>
<div id="luckPermsSection" class="card spaced-top" style="display:none;padding:12px;">
<h3 style="margin:0 0 8px;">LuckPerms</h3>
<div class="profile-grid">
<div class="profile-field"><span>Primary Group</span><strong id="lpPrimaryGroup">-</strong></div>
<div class="profile-field"><span>Prefix / Suffix</span><strong id="lpPrefixSuffix">-</strong></div>
</div>
<div class="profile-field spaced-top"><span>Groups</span><strong id="lpGroups">-</strong></div>
<div class="profile-field spaced-top"><span>Permissions</span><strong id="lpPermissionsMeta">-</strong></div>
<pre id="lpPermissions" class="log-rectangle" style="height:180px;margin-top:8px;"></pre>
</div>
<div id="playerManagementActions" class="spaced-top" style="display:none;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;">
<input id="actionReason" placeholder="Reason for action" style="grid-column:1 / -1;">
<input id="actionDuration" type="number" min="1" value="60" placeholder="Duration (minutes)">
<button id="kickButton" class="secondary">Kick</button>
<button id="tempBanButton">Temp Ban</button>
<button id="banButton">Ban</button>
<button id="unbanButton" class="secondary">Unban</button>
<button id="muteButton">Mute</button>
<button id="unmuteButton" class="secondary">Unmute</button>
</div>
<div id="airstrikeActions" class="spaced-top" style="display:none;grid-template-columns:180px minmax(0,1fr);gap:8px;align-items:center;">
<button id="airstrikeButton" class="danger-button">Airstrike</button>
<input id="airstrikeAmount" type="number" min="1" max="20000" step="1" value="5000" placeholder="TNT amount">
</div>
</div>
</div>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
let allPlayers = [];
let selectedUuid = null;
let playerManagementAvailable = false;
let luckPermsAvailable = false;
let playerStatsAvailable = false;
let airstrikeAvailable = false;
let mePermissions = [];
let selectedProfileRequestId = 0;
const avatarCache = new Map();
function isCurrentProfileRequest(uuid, requestId) {
return selectedUuid === uuid && selectedProfileRequestId === requestId;
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
mePermissions = Array.isArray(data.user.permissions) ? data.user.permissions : [];
}
function hasPermission(permissionName) {
return mePermissions.includes(permissionName);
}
async function detectPlayerManagementExtension() {
try {
const response = await fetch('/api/extensions/player-management/mute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const data = await response.json();
playerManagementAvailable = data.error !== 'not_found';
} catch {
playerManagementAvailable = false;
}
const actions = document.getElementById('playerManagementActions');
actions.style.display = playerManagementAvailable ? 'grid' : 'none';
}
async function detectLuckPermsExtension() {
const section = document.getElementById('luckPermsSection');
luckPermsAvailable = false;
section.style.display = 'none';
try {
const probeUuid = selectedUuid || (allPlayers.length > 0 ? allPlayers[0].uuid : null);
if (!probeUuid) {
return;
}
const data = await api(`/api/extensions/luckperms/player/${encodeURIComponent(probeUuid)}`);
luckPermsAvailable = data.available === true;
} catch {
luckPermsAvailable = false;
}
section.style.display = luckPermsAvailable ? 'block' : 'none';
}
async function detectPlayerStatsExtension() {
const section = document.getElementById('playerStatsSection');
playerStatsAvailable = false;
section.style.display = 'none';
try {
const probeUuid = selectedUuid || (allPlayers.length > 0 ? allPlayers[0].uuid : null);
if (!probeUuid) {
return;
}
const data = await api(`/api/extensions/player-stats/player/${encodeURIComponent(probeUuid)}`);
playerStatsAvailable = data.available === true;
} catch {
playerStatsAvailable = false;
}
section.style.display = playerStatsAvailable ? 'block' : 'none';
}
async function detectAirstrikeExtension() {
const actions = document.getElementById('airstrikeActions');
airstrikeAvailable = false;
actions.style.display = 'none';
if (!hasPermission('MANAGE_AIRSTRIKE')) {
return;
}
try {
const response = await fetch('/api/extensions/airstrike/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const data = await response.json();
airstrikeAvailable = data.error !== 'not_found';
} catch {
airstrikeAvailable = false;
}
actions.style.display = airstrikeAvailable ? 'block' : 'none';
}
function formatDate(timestamp) {
if (!timestamp || Number(timestamp) <= 0) {
return '-';
}
return new Date(Number(timestamp)).toLocaleString();
}
function formatPlaytime(seconds) {
const safe = Math.max(0, Number(seconds) || 0);
const days = Math.floor(safe / 86400);
const hours = Math.floor((safe % 86400) / 3600);
const minutes = Math.floor((safe % 3600) / 60);
const sec = Math.floor(safe % 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0 || days > 0) parts.push(`${hours}h`);
if (minutes > 0 || hours > 0 || days > 0) parts.push(`${minutes}m`);
parts.push(`${sec}s`);
return parts.join(' ');
}
function applyAvatar(img, uuid, username, requestId) {
img.dataset.avatarUuid = uuid;
img.dataset.avatarRequestId = String(requestId);
delete img.dataset.fallbackTried;
const cached = avatarCache.get(uuid);
if (cached) {
img.onerror = null;
img.onload = null;
img.src = cached;
return;
}
img.referrerPolicy = 'no-referrer';
img.alt = `${username} skin face`;
function avatarRequestStillCurrent() {
return img.dataset.avatarUuid === uuid
&& img.dataset.avatarRequestId === String(requestId)
&& isCurrentProfileRequest(uuid, requestId);
}
img.src = `https://crafatar.com/avatars/${uuid}?size=48&overlay`;
img.onerror = () => {
if (!avatarRequestStillCurrent()) {
return;
}
if (!img.dataset.fallbackTried) {
img.dataset.fallbackTried = '1';
img.src = `https://mc-heads.net/avatar/${encodeURIComponent(username)}/48`;
return;
}
img.onerror = null;
img.onload = null;
img.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><rect width="48" height="48" fill="%23131f38"/><text x="24" y="30" text-anchor="middle" font-family="Arial" font-size="18" fill="%23a9bbdf">?</text></svg>';
};
img.onload = () => {
if (!avatarRequestStillCurrent()) {
return;
}
const currentSource = img.currentSrc || img.src || '';
if (currentSource.startsWith('http')) {
avatarCache.set(uuid, currentSource);
}
};
}
async function loadPlayerList() {
const data = await api('/api/players');
allPlayers = Array.isArray(data.allPlayers) ? data.allPlayers : [];
document.getElementById('playerCount').textContent = String(allPlayers.length);
renderPlayerDirectory();
if (selectedUuid) {
const stillExists = allPlayers.some(player => player.uuid === selectedUuid);
if (stillExists) {
await loadPlayerProfile(selectedUuid);
}
}
}
function renderPlayerDirectory() {
const search = document.getElementById('playerSearch').value.trim().toLowerCase();
const list = document.getElementById('playerDirectory');
list.innerHTML = '';
const filtered = allPlayers.filter(player => {
if (!search) return true;
return `${player.username} ${player.uuid}`.toLowerCase().includes(search);
});
if (filtered.length === 0) {
const empty = document.createElement('li');
empty.className = 'player-empty';
empty.textContent = 'No players found';
list.appendChild(empty);
return;
}
filtered.forEach(player => {
const item = document.createElement('li');
item.className = `directory-item${player.uuid === selectedUuid ? ' active' : ''}`;
const dot = document.createElement('span');
dot.className = `status-dot ${player.online ? 'online' : 'offline'}`;
const name = document.createElement('span');
name.className = 'directory-name';
name.textContent = player.username;
item.appendChild(dot);
item.appendChild(name);
item.addEventListener('click', async () => {
selectedUuid = player.uuid;
renderPlayerDirectory();
await loadPlayerProfile(player.uuid);
});
list.appendChild(item);
});
}
async function loadMuteState(uuid, requestId) {
const muteState = document.getElementById('profileMuteState');
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
if (!playerManagementAvailable) {
muteState.textContent = 'Mute: extension not installed';
return;
}
muteState.textContent = 'Mute: -';
try {
const data = await api(`/api/extensions/player-management/mute/${encodeURIComponent(uuid)}`);
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
if (!data.muted) {
muteState.textContent = 'Mute: not muted';
return;
}
if (data.expiresAt) {
muteState.textContent = `Mute: active until ${new Date(Number(data.expiresAt)).toLocaleString()} (${data.reason || 'No reason'})`;
} else {
muteState.textContent = `Mute: active permanently (${data.reason || 'No reason'})`;
}
} catch {
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
muteState.textContent = 'Mute: unavailable';
}
}
async function loadPlayerProfile(uuid) {
const requestId = ++selectedProfileRequestId;
const data = await api(`/api/players/profile/${encodeURIComponent(uuid)}`);
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
const profile = data.profile;
document.getElementById('profileEmpty').style.display = 'none';
document.getElementById('profileContent').style.display = 'block';
document.getElementById('profileName').textContent = profile.username || 'Unknown';
document.getElementById('profileUuid').textContent = profile.uuid || '-';
const statusDot = document.getElementById('profileStatusDot');
statusDot.className = `status-dot ${profile.online ? 'online' : 'offline'}`;
document.getElementById('profileStatusText').textContent = profile.online ? 'Online' : 'Offline';
document.getElementById('profileFirstJoined').textContent = formatDate(profile.firstJoined);
document.getElementById('profileLastSeen').textContent = formatDate(profile.lastSeen);
document.getElementById('profilePlaytime').textContent = formatPlaytime(profile.totalPlaytimeSeconds);
document.getElementById('profileSessions').textContent = String(profile.totalSessions ?? 0);
document.getElementById('profileIp').textContent = profile.lastIp || '-';
document.getElementById('profileCountry').textContent = profile.country || 'Unknown';
applyAvatar(document.getElementById('profileAvatar'), profile.uuid, profile.username, requestId);
await loadMuteState(profile.uuid, requestId);
await loadPlayerStats(profile.uuid, requestId);
await loadLuckPermsState(profile.uuid, requestId);
}
async function loadPlayerStats(uuid, requestId) {
const section = document.getElementById('playerStatsSection');
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
if (!playerStatsAvailable) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
document.getElementById('statsKills').textContent = '-';
document.getElementById('statsDeaths').textContent = '-';
document.getElementById('statsMoney').textContent = '-';
document.getElementById('statsEconomyProvider').textContent = '-';
try {
const data = await api(`/api/extensions/player-stats/player/${encodeURIComponent(uuid)}`);
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
if (!data.available) {
section.style.display = 'none';
return;
}
document.getElementById('statsKills').textContent = String(data.kills ?? 0);
document.getElementById('statsDeaths').textContent = String(data.deaths ?? 0);
if (data.economyAvailable) {
document.getElementById('statsMoney').textContent = data.balanceFormatted || '-';
document.getElementById('statsEconomyProvider').textContent = data.economyProvider || '-';
} else {
document.getElementById('statsMoney').textContent = 'Economy not available';
document.getElementById('statsEconomyProvider').textContent = '-';
}
} catch {
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
section.style.display = 'none';
}
}
async function loadLuckPermsState(uuid, requestId) {
const section = document.getElementById('luckPermsSection');
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
if (!luckPermsAvailable) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
document.getElementById('lpPrimaryGroup').textContent = '-';
document.getElementById('lpPrefixSuffix').textContent = '-';
document.getElementById('lpGroups').textContent = '-';
document.getElementById('lpPermissionsMeta').textContent = 'Loading...';
document.getElementById('lpPermissions').textContent = '';
try {
const data = await api(`/api/extensions/luckperms/player/${encodeURIComponent(uuid)}`);
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
if (!data.available) {
section.style.display = 'none';
return;
}
const groups = Array.isArray(data.groups) ? data.groups : [];
const permissions = Array.isArray(data.permissions) ? data.permissions : [];
document.getElementById('lpPrimaryGroup').textContent = data.primaryGroup || '-';
document.getElementById('lpPrefixSuffix').textContent = `${data.prefix || '-'} / ${data.suffix || '-'}`;
document.getElementById('lpGroups').textContent = groups.length > 0 ? groups.join(', ') : '-';
const totalPermissions = Number(data.permissionsCount) || permissions.length;
const truncated = data.permissionsTruncated === true;
document.getElementById('lpPermissionsMeta').textContent = truncated
? `${permissions.length} shown of ${totalPermissions}`
: `${totalPermissions} permission(s)`;
document.getElementById('lpPermissions').textContent = permissions.length > 0
? permissions.join('\n')
: 'No granted permissions';
} catch {
if (!isCurrentProfileRequest(uuid, requestId)) {
return;
}
section.style.display = 'none';
}
}
function selectedPlayerName() {
if (!selectedUuid) return '';
const player = allPlayers.find(x => x.uuid === selectedUuid);
return player ? player.username : '';
}
function actionReason(defaultValue) {
const raw = document.getElementById('actionReason').value.trim();
return raw || defaultValue;
}
function actionDuration() {
const parsed = Number(document.getElementById('actionDuration').value);
if (!Number.isFinite(parsed)) return 60;
return Math.max(1, Math.min(43200, Math.round(parsed)));
}
function actionTntAmount() {
const parsed = Number(document.getElementById('airstrikeAmount').value);
if (!Number.isFinite(parsed)) return 5000;
return Math.max(1, Math.min(20000, Math.round(parsed)));
}
async function runPlayerAction(handler) {
if (!selectedUuid) {
alert('Select a player first.');
return;
}
await handler();
await loadPlayerList();
await loadPlayerProfile(selectedUuid);
}
document.getElementById('kickButton').addEventListener('click', async () => {
await runPlayerAction(async () => {
await api(`/api/players/${encodeURIComponent(selectedUuid)}/kick`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: actionReason('Kicked by panel moderator') })
});
});
});
document.getElementById('tempBanButton').addEventListener('click', async () => {
await runPlayerAction(async () => {
await api(`/api/players/${encodeURIComponent(selectedUuid)}/temp-ban`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: selectedPlayerName(),
durationMinutes: actionDuration(),
reason: actionReason('Temporarily banned by panel moderator')
})
});
});
});
document.getElementById('banButton').addEventListener('click', async () => {
await runPlayerAction(async () => {
await api('/api/players/ban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uuid: selectedUuid,
username: selectedPlayerName(),
reason: actionReason('Banned by panel moderator')
})
});
});
});
document.getElementById('unbanButton').addEventListener('click', async () => {
await runPlayerAction(async () => {
await api('/api/players/unban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: selectedUuid, username: selectedPlayerName() })
});
});
});
document.getElementById('muteButton').addEventListener('click', async () => {
await runPlayerAction(async () => {
await api('/api/extensions/player-management/mute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uuid: selectedUuid,
username: selectedPlayerName(),
durationMinutes: actionDuration(),
reason: actionReason('Muted by panel moderator')
})
});
});
});
document.getElementById('unmuteButton').addEventListener('click', async () => {
await runPlayerAction(async () => {
await api('/api/extensions/player-management/unmute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: selectedUuid, username: selectedPlayerName() })
});
});
});
document.getElementById('airstrikeButton').addEventListener('click', async () => {
if (!airstrikeAvailable) {
alert('Airstrike extension is not available.');
return;
}
await runPlayerAction(async () => {
await api('/api/extensions/airstrike/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uuid: selectedUuid,
username: selectedPlayerName(),
amount: actionTntAmount()
})
});
});
});
document.getElementById('playerSearch').addEventListener('input', renderPlayerDirectory);
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await Promise.all([loadMe(), detectPlayerManagementExtension(), loadPlayerList()]);
await detectPlayerStatsExtension();
await detectLuckPermsExtension();
await detectAirstrikeExtension();
if (allPlayers.length > 0) {
selectedUuid = allPlayers[0].uuid;
renderPlayerDirectory();
await loadPlayerProfile(selectedUuid);
}
setInterval(() => {
loadPlayerList().catch(() => {});
}, 5000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,368 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Plugins</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link active" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Plugins</h1>
<p>Installed server plugins: <span id="pluginCount">0</span></p>
<div class="tabs">
<button id="tabInstalled" class="tab-link active" type="button">Installed</button>
<button id="tabInstall" class="tab-link" type="button">Install</button>
</div>
<section id="installedSection" class="card spaced-top">
<h2>Installed Plugins</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Version</th>
<th>Enabled</th>
<th>Main Class</th>
<th>Authors</th>
</tr>
</thead>
<tbody id="plugins"></tbody>
</table>
</div>
</section>
<section id="installSection" class="card spaced-top" style="display:none;">
<h2>Install From Marketplace</h2>
<p>Search results are filtered to Paper-compatible plugins.</p>
<div class="marketplace-search-row">
<select id="marketSource">
<option value="modrinth">Modrinth</option>
<option value="curseforge">CurseForge</option>
<option value="hangar">Hangar</option>
</select>
<input id="marketQuery" placeholder="Search Paper plugin name">
<button id="searchMarketplace" class="secondary" type="button">Search</button>
</div>
<div class="table-wrap spaced-top">
<table>
<thead>
<tr>
<th>Name</th>
<th>Author</th>
<th>Description</th>
<th>Versions</th>
<th>Install</th>
</tr>
</thead>
<tbody id="marketResults"></tbody>
</table>
</div>
<p id="marketStatus" class="action-status"></p>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
const projectVersions = new Map();
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function renderPluginRow(plugin) {
const tr = document.createElement('tr');
const nameTd = document.createElement('td');
nameTd.textContent = plugin.name || 'Unknown';
tr.appendChild(nameTd);
const versionTd = document.createElement('td');
versionTd.textContent = plugin.version || '-';
tr.appendChild(versionTd);
const enabledTd = document.createElement('td');
enabledTd.textContent = plugin.enabled ? 'Yes' : 'No';
tr.appendChild(enabledTd);
const mainTd = document.createElement('td');
mainTd.textContent = plugin.main || '-';
tr.appendChild(mainTd);
const authorsTd = document.createElement('td');
const authors = Array.isArray(plugin.authors) ? plugin.authors : [];
authorsTd.textContent = authors.length > 0 ? authors.join(', ') : '-';
tr.appendChild(authorsTd);
return tr;
}
async function loadPlugins() {
const data = await api('/api/plugins');
document.getElementById('pluginCount').textContent = data.count ?? 0;
const tbody = document.getElementById('plugins');
tbody.innerHTML = '';
if (!Array.isArray(data.plugins) || data.plugins.length === 0) {
const emptyRow = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 5;
cell.textContent = 'No plugins found';
emptyRow.appendChild(cell);
tbody.appendChild(emptyRow);
return;
}
data.plugins.forEach(plugin => {
tbody.appendChild(renderPluginRow(plugin));
});
}
function setTab(installedVisible) {
document.getElementById('installedSection').style.display = installedVisible ? 'block' : 'none';
document.getElementById('installSection').style.display = installedVisible ? 'none' : 'block';
document.getElementById('tabInstalled').classList.toggle('active', installedVisible);
document.getElementById('tabInstall').classList.toggle('active', !installedVisible);
}
function setMarketStatus(message, isError) {
const status = document.getElementById('marketStatus');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function fetchVersionsForProject(source, projectId) {
const key = `${source}:${projectId}`;
if (projectVersions.has(key)) {
return projectVersions.get(key);
}
const data = await api(`/api/plugin-marketplace/versions?source=${encodeURIComponent(source)}&projectId=${encodeURIComponent(projectId)}`);
const versions = Array.isArray(data.versions) ? data.versions : [];
projectVersions.set(key, versions);
return versions;
}
function createMarketRow(source, project) {
const tr = document.createElement('tr');
const nameTd = document.createElement('td');
nameTd.textContent = project.name || 'Unknown';
tr.appendChild(nameTd);
const authorTd = document.createElement('td');
authorTd.textContent = project.author || '-';
tr.appendChild(authorTd);
const descTd = document.createElement('td');
descTd.textContent = project.description || '-';
tr.appendChild(descTd);
const versionsTd = document.createElement('td');
const versionSelect = document.createElement('select');
versionSelect.disabled = true;
versionsTd.appendChild(versionSelect);
tr.appendChild(versionsTd);
const installTd = document.createElement('td');
const installBtn = document.createElement('button');
installBtn.textContent = 'Install';
installBtn.disabled = true;
installTd.appendChild(installBtn);
tr.appendChild(installTd);
(async () => {
try {
const versions = await fetchVersionsForProject(source, project.projectId);
versionSelect.innerHTML = '';
if (versions.length === 0) {
const option = document.createElement('option');
option.textContent = 'No versions';
versionSelect.appendChild(option);
return;
}
versions.forEach(version => {
const option = document.createElement('option');
option.value = version.versionId;
option.textContent = version.name || version.versionId;
versionSelect.appendChild(option);
});
versionSelect.disabled = false;
installBtn.disabled = false;
} catch (error) {
setMarketStatus(`Failed to load versions for ${project.name}: ${error.message}`, true);
}
})();
installBtn.addEventListener('click', async () => {
if (!versionSelect.value) {
return;
}
try {
installBtn.disabled = true;
const result = await api('/api/plugin-marketplace/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source,
projectId: project.projectId,
versionId: versionSelect.value
})
});
setMarketStatus(`Installed ${result.fileName}. Restart required to load the plugin.`, false);
await loadPlugins();
} catch (error) {
setMarketStatus(`Install failed: ${error.message}`, true);
} finally {
installBtn.disabled = false;
}
});
return tr;
}
async function searchMarketplace() {
const source = document.getElementById('marketSource').value;
const query = document.getElementById('marketQuery').value.trim();
if (!query) {
setMarketStatus('Enter a search term first', true);
return;
}
const data = await api(`/api/plugin-marketplace/search?source=${encodeURIComponent(source)}&query=${encodeURIComponent(query)}`);
const results = Array.isArray(data.results) ? data.results : [];
const tbody = document.getElementById('marketResults');
tbody.innerHTML = '';
if (results.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 5;
cell.textContent = 'No marketplace results found';
row.appendChild(cell);
tbody.appendChild(row);
setMarketStatus('', false);
return;
}
results.forEach(project => {
tbody.appendChild(createMarketRow(source, project));
});
setMarketStatus(`Found ${results.length} result(s) in ${source}.`, false);
}
document.getElementById('tabInstalled').addEventListener('click', () => setTab(true));
document.getElementById('tabInstall').addEventListener('click', () => setTab(false));
document.getElementById('searchMarketplace').addEventListener('click', async () => {
try {
await searchMarketplace();
} catch (error) {
setMarketStatus(`Search failed: ${error.message}`, true);
}
});
document.getElementById('marketQuery').addEventListener('keydown', async event => {
if (event.key === 'Enter') {
event.preventDefault();
try {
await searchMarketplace();
} catch (error) {
setMarketStatus(`Search failed: ${error.message}`, true);
}
}
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await Promise.all([loadMe(), loadPlugins()]);
setInterval(() => {
loadPlugins().catch(() => {});
}, 5000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,205 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Reports</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
<a class="side-link active" href="/dashboard/reports">Reports</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Reports</h1>
<div class="toolbar">
<select id="reportStatusFilter" style="width:auto;min-width:180px;">
<option value="">All statuses</option>
<option value="OPEN">Open</option>
<option value="RESOLVED">Resolved</option>
</select>
<button id="refreshReports" class="secondary">Refresh</button>
</div>
<section class="card">
<h2>Player Reports</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Reporter</th>
<th>Suspect</th>
<th>Reason</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="reportsBody"></tbody>
</table>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
function formatDate(timestamp) {
if (!timestamp || Number(timestamp) <= 0) return '-';
return new Date(Number(timestamp)).toLocaleString();
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
async function loadReports() {
const status = document.getElementById('reportStatusFilter').value;
const url = status ? `/api/extensions/reports?status=${encodeURIComponent(status)}` : '/api/extensions/reports';
const data = await api(url);
const body = document.getElementById('reportsBody');
body.innerHTML = '';
if (!Array.isArray(data.reports) || data.reports.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 7;
cell.textContent = 'No reports found';
row.appendChild(cell);
body.appendChild(row);
return;
}
data.reports.forEach(report => {
const row = document.createElement('tr');
row.innerHTML = `
<td>#${report.id}</td>
<td>${report.reporterName}</td>
<td>${report.suspectName}</td>
<td>${report.reason}</td>
<td>${report.status}</td>
<td>${formatDate(report.createdAt)}</td>
<td></td>
`;
const actionsCell = row.lastElementChild;
if (report.status === 'OPEN') {
const resolveButton = document.createElement('button');
resolveButton.className = 'secondary';
resolveButton.style.width = 'auto';
resolveButton.textContent = 'Resolve';
resolveButton.addEventListener('click', async () => {
await api(`/api/extensions/reports/${report.id}/resolve`, { method: 'POST' });
await loadReports();
});
const banButton = document.createElement('button');
banButton.style.width = 'auto';
banButton.textContent = 'Ban 60m';
banButton.addEventListener('click', async () => {
await api(`/api/extensions/reports/${report.id}/ban`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ durationMinutes: 60, reason: `Auto-ban from report #${report.id}` })
});
await loadReports();
});
actionsCell.appendChild(resolveButton);
actionsCell.appendChild(document.createTextNode(' '));
actionsCell.appendChild(banButton);
} else {
actionsCell.textContent = '-';
}
body.appendChild(row);
});
}
document.getElementById('refreshReports').addEventListener('click', loadReports);
document.getElementById('reportStatusFilter').addEventListener('change', loadReports);
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadReports();
setInterval(() => loadReports().catch(() => {}), 5000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,172 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Resources</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link active" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Resources</h1>
<section class="stats-grid spaced-top">
<div class="card stat-card">
<div class="stat-label">CPU Load (System)</div>
<div id="statCpuSystem" class="stat-value">0%</div>
</div>
<div class="card stat-card">
<div class="stat-label">CPU Load (MinePanel Process)</div>
<div id="statCpuProcess" class="stat-value">0%</div>
</div>
<div class="card stat-card">
<div class="stat-label">Memory Used</div>
<div id="statMemory" class="stat-value">0 MB</div>
</div>
<div class="card stat-card">
<div class="stat-label">Disk Used</div>
<div id="statDisk" class="stat-value">0 GB</div>
</div>
</section>
<section class="card spaced-top">
<h2>Server Runtime</h2>
<pre id="runtimeInfo" class="log-rectangle short"></pre>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function toPercent(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return 'Unavailable';
}
return `${Math.round(numeric * 1000) / 10}%`;
}
function toMb(bytes) {
return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
}
function toGb(bytes) {
return `${Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100} GB`;
}
async function loadHealth() {
const health = await api('/api/health');
document.getElementById('statCpuSystem').textContent = toPercent(health.cpuSystemLoad);
document.getElementById('statCpuProcess').textContent = toPercent(health.cpuProcessLoad);
document.getElementById('statMemory').textContent = `${toMb(health.memoryUsed || 0)} / ${toMb(health.memoryMax || 0)}`;
if ((health.diskUsed || -1) >= 0 && (health.diskTotal || -1) >= 0) {
document.getElementById('statDisk').textContent = `${toGb(health.diskUsed)} / ${toGb(health.diskTotal)}`;
} else {
document.getElementById('statDisk').textContent = 'Unavailable';
}
document.getElementById('runtimeInfo').textContent = [
`Server: ${health.serverName || 'unknown'}`,
`Version: ${health.serverVersion || 'unknown'}`,
`Bukkit: ${health.bukkitVersion || 'unknown'}`,
`CPU Cores: ${health.cpuCores || 0}`,
`Memory Total: ${toMb(health.memoryTotal || 0)}`,
`Memory Free: ${toMb((health.memoryTotal || 0) - (health.memoryUsed || 0))}`,
`Disk Free: ${(health.diskFree || -1) >= 0 ? toGb(health.diskFree) : 'Unavailable'}`
].join('\n');
}
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await Promise.all([loadMe(), loadHealth()]);
setInterval(() => {
loadHealth().catch(() => {});
}, 3000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,583 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Themes</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link active" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Themes</h1>
<p>Pick a preset or customize panel colors.</p>
<section class="card spaced-top">
<h2>Presets</h2>
<div class="theme-preset-row">
<select id="themePreset">
<option value="default">Default (MinePanel)</option>
<option value="midnight">Midnight Blue</option>
<option value="forest">Forest Dark</option>
<option value="ember">Ember Dark</option>
</select>
<button id="applyPreset" type="button">Apply Preset</button>
</div>
</section>
<section class="card spaced-top">
<h2>Custom Colors</h2>
<div id="themeGrid" class="theme-grid"></div>
<div class="theme-actions">
<button id="saveTheme" type="button">Save Custom Theme</button>
<button id="resetTheme" class="secondary" type="button">Reset to Default</button>
</div>
<p id="themeStatus" class="action-status"></p>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
const STORAGE_KEY = window.MinePanelTheme ? window.MinePanelTheme.storageKey : 'minepanel.customTheme';
const THEME_KEYS = [
'--bg',
'--surface',
'--surface-2',
'--border',
'--text',
'--text-muted',
'--accent',
'--accent-strong',
'--danger-bg',
'--danger-text',
'--log-bg',
'--sidebar-bg',
'--sidebar-border',
'--sidebar-meta-bg',
'--sidebar-meta-border',
'--sidebar-toggle-text',
'--sidebar-toggle-icon',
'--sidebar-link-text',
'--sidebar-link-bg',
'--sidebar-link-border',
'--sidebar-link-hover-bg',
'--button-bg',
'--button-border',
'--button-hover-bg',
'--button-secondary-bg',
'--button-secondary-border',
'--input-bg',
'--focus-ring',
'--table-bg',
'--table-head-bg',
'--table-row-border',
'--table-row-hover-bg',
'--table-head-text',
'--table-cell-text',
'--log-border',
'--log-text',
'--chart-bg',
'--chart-border',
'--chart-grid',
'--chart-label',
'--player-box-bg',
'--player-box-border',
'--player-box-hover-bg',
'--player-box-active-bg',
'--player-box-active-border',
'--player-muted-bg',
'--player-muted-border'
];
const THEME_GROUPS = [
{
title: 'Core',
keys: [
'--bg',
'--surface',
'--surface-2',
'--border',
'--text',
'--text-muted',
'--accent',
'--accent-strong',
'--danger-bg',
'--danger-text'
]
},
{
title: 'Sidebar',
keys: [
'--sidebar-bg',
'--sidebar-border',
'--sidebar-meta-bg',
'--sidebar-meta-border',
'--sidebar-toggle-text',
'--sidebar-toggle-icon',
'--sidebar-link-text',
'--sidebar-link-bg',
'--sidebar-link-border',
'--sidebar-link-hover-bg'
]
},
{
title: 'Buttons and Inputs',
keys: [
'--button-bg',
'--button-border',
'--button-hover-bg',
'--button-secondary-bg',
'--button-secondary-border',
'--input-bg',
'--focus-ring'
]
},
{
title: 'Logs and Tables',
keys: [
'--log-bg',
'--log-border',
'--log-text',
'--table-bg',
'--table-head-bg',
'--table-row-border',
'--table-row-hover-bg',
'--table-head-text',
'--table-cell-text'
]
},
{
title: 'Charts',
keys: [
'--chart-bg',
'--chart-border',
'--chart-grid',
'--chart-label'
]
},
{
title: 'Players',
keys: [
'--player-box-bg',
'--player-box-border',
'--player-box-hover-bg',
'--player-box-active-bg',
'--player-box-active-border',
'--player-muted-bg',
'--player-muted-border'
]
}
];
const PRESETS = {
default: {
'--bg': '#0b1220',
'--surface': '#111a2e',
'--surface-2': '#16233d',
'--border': '#24324f',
'--text': '#e5ebf8',
'--text-muted': '#99a8c6',
'--accent': '#4f8cff',
'--accent-strong': '#3a75e8',
'--danger-bg': '#3d1d29',
'--danger-text': '#ffb7c7',
'--log-bg': '#0a101d',
'--sidebar-bg': '#060b16',
'--sidebar-border': '#1b2942',
'--sidebar-meta-bg': '#0d1528',
'--sidebar-meta-border': '#1f3050',
'--sidebar-toggle-text': '#7f93b8',
'--sidebar-toggle-icon': '#8fa6d0',
'--sidebar-link-text': '#c7d7f7',
'--sidebar-link-bg': '#0f1930',
'--sidebar-link-border': '#263a5d',
'--sidebar-link-hover-bg': '#162642',
'--button-bg': '#4f8cff',
'--button-border': '#3a75e8',
'--button-hover-bg': '#5a95ff',
'--button-secondary-bg': '#21314d',
'--button-secondary-border': '#344769',
'--input-bg': '#0f1930',
'--focus-ring': '#4f8cff',
'--table-bg': '#0e172b',
'--table-head-bg': '#13203b',
'--table-row-border': '#1f2d49',
'--table-row-hover-bg': '#111d35',
'--table-head-text': '#d7e2f8',
'--table-cell-text': '#c7d4ee',
'--log-border': '#213457',
'--log-text': '#d6e0f8',
'--chart-bg': '#0b1427',
'--chart-border': '#213457',
'--chart-grid': '#7896d2',
'--chart-label': '#8ea6d2',
'--player-box-bg': '#0f1930',
'--player-box-border': '#23365a',
'--player-box-hover-bg': '#162642',
'--player-box-active-bg': '#1b2c4a',
'--player-box-active-border': '#3f73cf',
'--player-muted-bg': '#101a2f',
'--player-muted-border': '#2a3f63'
},
midnight: {
'--bg': '#070b17',
'--surface': '#0f1a30',
'--surface-2': '#132445',
'--border': '#263f67',
'--text': '#e6f0ff',
'--text-muted': '#97abd2',
'--accent': '#4f8cff',
'--accent-strong': '#2c6fe2',
'--danger-bg': '#3b1a2f',
'--danger-text': '#ffb5c6',
'--log-bg': '#090f1d',
'--sidebar-bg': '#050915',
'--sidebar-border': '#1b3158',
'--sidebar-meta-bg': '#0c1730',
'--sidebar-meta-border': '#1f3a67',
'--sidebar-toggle-text': '#8aa1ca',
'--sidebar-toggle-icon': '#9cb4e2',
'--sidebar-link-text': '#d2e2ff',
'--sidebar-link-bg': '#11203b',
'--sidebar-link-border': '#2b4773',
'--sidebar-link-hover-bg': '#183055',
'--button-bg': '#4f8cff',
'--button-border': '#2c6fe2',
'--button-hover-bg': '#5f98ff',
'--button-secondary-bg': '#23385e',
'--button-secondary-border': '#35507e',
'--input-bg': '#11203b',
'--focus-ring': '#4f8cff',
'--table-bg': '#0f1a31',
'--table-head-bg': '#13274a',
'--table-row-border': '#2a4773',
'--table-row-hover-bg': '#183157',
'--table-head-text': '#d7e6ff',
'--table-cell-text': '#cddcff',
'--log-border': '#2b4a78',
'--log-text': '#d4e3ff',
'--chart-bg': '#0f1d38',
'--chart-border': '#2b4a78',
'--chart-grid': '#87a7df',
'--chart-label': '#a3b8df',
'--player-box-bg': '#11203b',
'--player-box-border': '#2b4773',
'--player-box-hover-bg': '#183055',
'--player-box-active-bg': '#223c69',
'--player-box-active-border': '#4b82de',
'--player-muted-bg': '#0f1b34',
'--player-muted-border': '#2a4268'
},
forest: {
'--bg': '#0b1612',
'--surface': '#11231b',
'--surface-2': '#173026',
'--border': '#29503f',
'--text': '#e7fff5',
'--text-muted': '#97c7b0',
'--accent': '#3fbf7f',
'--accent-strong': '#2ca866',
'--danger-bg': '#3d1d29',
'--danger-text': '#ffb7c7',
'--log-bg': '#0b1511',
'--sidebar-bg': '#08130f',
'--sidebar-border': '#1f3f33',
'--sidebar-meta-bg': '#10231c',
'--sidebar-meta-border': '#2a4f40',
'--sidebar-toggle-text': '#8db8a3',
'--sidebar-toggle-icon': '#9fceb8',
'--sidebar-link-text': '#d6f5e7',
'--sidebar-link-bg': '#133026',
'--sidebar-link-border': '#2f5f4b',
'--sidebar-link-hover-bg': '#1a3d31',
'--button-bg': '#3fbf7f',
'--button-border': '#2ca866',
'--button-hover-bg': '#4dce8d',
'--button-secondary-bg': '#24493b',
'--button-secondary-border': '#356652',
'--input-bg': '#133026',
'--focus-ring': '#3fbf7f',
'--table-bg': '#11271f',
'--table-head-bg': '#173629',
'--table-row-border': '#2e5b48',
'--table-row-hover-bg': '#1a3f31',
'--table-head-text': '#daf5e8',
'--table-cell-text': '#c8ebda',
'--log-border': '#2e5a49',
'--log-text': '#d9f4e8',
'--chart-bg': '#10261e',
'--chart-border': '#2e5a49',
'--chart-grid': '#7fb6a1',
'--chart-label': '#94c7b3',
'--player-box-bg': '#133026',
'--player-box-border': '#2f5f4b',
'--player-box-hover-bg': '#1a3d31',
'--player-box-active-bg': '#23503f',
'--player-box-active-border': '#43a379',
'--player-muted-bg': '#11271f',
'--player-muted-border': '#2c5342'
},
ember: {
'--bg': '#18100b',
'--surface': '#2a1a11',
'--surface-2': '#3a2416',
'--border': '#5a3a23',
'--text': '#fff0e8',
'--text-muted': '#d6b8a4',
'--accent': '#ff8f4f',
'--accent-strong': '#e67839',
'--danger-bg': '#4a1f22',
'--danger-text': '#ffb7b7',
'--log-bg': '#1a120d',
'--sidebar-bg': '#140d08',
'--sidebar-border': '#4c311f',
'--sidebar-meta-bg': '#23160e',
'--sidebar-meta-border': '#5e3b26',
'--sidebar-toggle-text': '#d1a78f',
'--sidebar-toggle-icon': '#e0b69e',
'--sidebar-link-text': '#ffe4d2',
'--sidebar-link-bg': '#382116',
'--sidebar-link-border': '#6f4228',
'--sidebar-link-hover-bg': '#4a2c1d',
'--button-bg': '#ff8f4f',
'--button-border': '#e67839',
'--button-hover-bg': '#ffa366',
'--button-secondary-bg': '#5a3622',
'--button-secondary-border': '#7a4a30',
'--input-bg': '#382116',
'--focus-ring': '#ff8f4f',
'--table-bg': '#2f1d13',
'--table-head-bg': '#432719',
'--table-row-border': '#6b4027',
'--table-row-hover-bg': '#4b2e1f',
'--table-head-text': '#ffe7da',
'--table-cell-text': '#f2d2bf',
'--log-border': '#70442a',
'--log-text': '#ffe4d5',
'--chart-bg': '#341f14',
'--chart-border': '#70442a',
'--chart-grid': '#c18e71',
'--chart-label': '#d2a98f',
'--player-box-bg': '#382116',
'--player-box-border': '#6f4228',
'--player-box-hover-bg': '#4a2c1d',
'--player-box-active-bg': '#603924',
'--player-box-active-border': '#ff9a63',
'--player-muted-bg': '#311d12',
'--player-muted-border': '#5b3521'
}
};
function setStatus(message, isError) {
const status = document.getElementById('themeStatus');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
function currentValueForKey(key) {
const value = getComputedStyle(document.documentElement).getPropertyValue(key).trim();
return value || PRESETS.default[key] || '#000000';
}
function normalizeHex(value) {
if (!value) return '#000000';
const trimmed = value.trim();
if (!trimmed.startsWith('#')) return '#000000';
if (trimmed.length === 7) return trimmed;
if (trimmed.length === 4) {
return `#${trimmed[1]}${trimmed[1]}${trimmed[2]}${trimmed[2]}${trimmed[3]}${trimmed[3]}`;
}
return '#000000';
}
function buildThemeField(key) {
const row = document.createElement('label');
row.className = 'theme-field';
row.setAttribute('for', `theme-${key.replace(/[^a-z0-9]/gi, '')}`);
const name = document.createElement('span');
name.textContent = key;
const input = document.createElement('input');
input.type = 'color';
input.id = `theme-${key.replace(/[^a-z0-9]/gi, '')}`;
input.dataset.themeKey = key;
input.value = normalizeHex(currentValueForKey(key));
input.addEventListener('input', () => {
document.documentElement.style.setProperty(key, input.value);
});
row.appendChild(name);
row.appendChild(input);
return row;
}
function buildThemeGroup(title, keys) {
const section = document.createElement('section');
section.className = 'theme-group';
const heading = document.createElement('h3');
heading.className = 'theme-group-title';
heading.textContent = title;
const content = document.createElement('div');
content.className = 'theme-group-grid';
keys.forEach(key => {
content.appendChild(buildThemeField(key));
});
section.appendChild(heading);
section.appendChild(content);
return section;
}
function buildThemeGrid() {
const grid = document.getElementById('themeGrid');
grid.innerHTML = '';
const usedKeys = new Set();
THEME_GROUPS.forEach(group => {
const validKeys = group.keys.filter(key => THEME_KEYS.includes(key));
if (validKeys.length === 0) {
return;
}
validKeys.forEach(key => usedKeys.add(key));
grid.appendChild(buildThemeGroup(group.title, validKeys));
});
const ungrouped = THEME_KEYS.filter(key => !usedKeys.has(key));
if (ungrouped.length > 0) {
grid.appendChild(buildThemeGroup('Other', ungrouped));
}
}
function collectThemeFromInputs() {
const theme = {};
document.querySelectorAll('input[data-theme-key]').forEach(input => {
theme[input.dataset.themeKey] = input.value;
});
return theme;
}
function applyTheme(theme) {
if (window.MinePanelTheme) {
window.MinePanelTheme.applyTheme(theme);
} else {
Object.entries(theme).forEach(([key, value]) => {
document.documentElement.style.setProperty(key, value);
});
}
buildThemeGrid();
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
document.getElementById('applyPreset').addEventListener('click', () => {
const preset = document.getElementById('themePreset').value;
const theme = PRESETS[preset] || PRESETS.default;
applyTheme(theme);
setStatus(`Applied ${preset} preset. Save to keep it.`, false);
});
document.getElementById('saveTheme').addEventListener('click', () => {
const theme = collectThemeFromInputs();
localStorage.setItem(STORAGE_KEY, JSON.stringify(theme));
setStatus('Custom theme saved.', false);
});
document.getElementById('resetTheme').addEventListener('click', () => {
localStorage.removeItem(STORAGE_KEY);
applyTheme(PRESETS.default);
setStatus('Theme reset to default.', false);
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
buildThemeGrid();
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,200 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Tickets</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
<a class="side-link" href="/dashboard/reports">Reports</a>
<a class="side-link active" href="/dashboard/tickets">Tickets</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Tickets</h1>
<div class="toolbar">
<select id="ticketStatusFilter" style="width:auto;min-width:180px;">
<option value="">All statuses</option>
<option value="OPEN">Open</option>
<option value="CLOSED">Closed</option>
</select>
<button id="refreshTickets" class="secondary">Refresh</button>
</div>
<section class="card">
<h2>Player Tickets</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Player</th>
<th>Category</th>
<th>Description</th>
<th>Status</th>
<th>Created</th>
<th>Handled By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="ticketsBody"></tbody>
</table>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
function formatDate(timestamp) {
if (!timestamp || Number(timestamp) <= 0) return '-';
return new Date(Number(timestamp)).toLocaleString();
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
async function loadTickets() {
const status = document.getElementById('ticketStatusFilter').value;
const url = status ? `/api/extensions/tickets?status=${encodeURIComponent(status)}` : '/api/extensions/tickets';
const data = await api(url);
const body = document.getElementById('ticketsBody');
body.innerHTML = '';
if (!Array.isArray(data.tickets) || data.tickets.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 8;
cell.textContent = 'No tickets found';
row.appendChild(cell);
body.appendChild(row);
return;
}
data.tickets.forEach(ticket => {
const row = document.createElement('tr');
row.innerHTML = `
<td>#${ticket.id}</td>
<td>${ticket.creatorName}</td>
<td>${ticket.category}</td>
<td>${ticket.description}</td>
<td>${ticket.status}</td>
<td>${formatDate(ticket.createdAt)}</td>
<td>${ticket.handledBy || '-'}</td>
<td></td>
`;
const actionsCell = row.lastElementChild;
if (ticket.status === 'OPEN') {
const closeButton = document.createElement('button');
closeButton.className = 'secondary';
closeButton.style.width = 'auto';
closeButton.textContent = 'Close';
closeButton.addEventListener('click', async () => {
await api(`/api/extensions/tickets/${ticket.id}/close`, { method: 'POST' });
await loadTickets();
});
actionsCell.appendChild(closeButton);
} else {
const reopenButton = document.createElement('button');
reopenButton.style.width = 'auto';
reopenButton.textContent = 'Reopen';
reopenButton.addEventListener('click', async () => {
await api(`/api/extensions/tickets/${ticket.id}/reopen`, { method: 'POST' });
await loadTickets();
});
actionsCell.appendChild(reopenButton);
}
body.appendChild(row);
});
}
document.getElementById('refreshTickets').addEventListener('click', loadTickets);
document.getElementById('ticketStatusFilter').addEventListener('change', loadTickets);
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadTickets();
setInterval(() => loadTickets().catch(() => {}), 5000);
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
+624
View File
@@ -0,0 +1,624 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Users</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
white-space: nowrap;
}
.permissions-shell {
display: grid;
gap: 10px;
}
.permission-group {
border: 1px solid var(--theme-border, rgba(255, 255, 255, 0.14));
border-radius: 10px;
background: var(--theme-surface-2, rgba(16, 20, 30, 0.82));
overflow: hidden;
}
.permission-group details {
display: block;
}
.permission-group-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border: none;
border-bottom: 1px solid transparent;
background: transparent;
color: inherit;
padding: 10px 12px;
text-align: left;
cursor: pointer;
list-style: none;
}
.permission-group-toggle::-webkit-details-marker {
display: none;
}
.permission-group.open .permission-group-toggle {
border-bottom-color: var(--theme-border, rgba(255, 255, 255, 0.12));
}
.permission-group-title {
font-weight: 600;
font-size: 14px;
}
.permission-group-meta {
font-size: 12px;
opacity: 0.75;
}
.permission-group-chevron {
font-size: 12px;
opacity: 0.75;
transition: transform 0.15s ease;
}
.permission-group.open .permission-group-chevron {
transform: rotate(180deg);
}
.permission-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
padding: 10px 12px 12px;
}
.permission-row {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--theme-border, rgba(255, 255, 255, 0.1));
border-radius: 8px;
padding: 7px 9px;
background: var(--theme-surface-3, rgba(255, 255, 255, 0.02));
}
.permission-row input[type="checkbox"] {
margin: 0;
}
.permission-row.disabled {
opacity: 0.6;
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link active" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Users</h1>
<section class="card spaced-top">
<h2>User Management</h2>
<div class="inline-form">
<button id="openCreateWizard" type="button">New User</button>
</div>
<div id="usersMainContent" class="spaced-top">
<div class="table-wrap">
<table>
<thead><tr><th>ID</th><th>Username</th><th>Role</th><th>Save Role</th><th>Permissions</th><th>Delete</th></tr></thead>
<tbody id="users"></tbody>
</table>
</div>
<div id="permissionEditor" class="spaced-top" style="display:none;">
<h3 id="permissionEditorTitle">Edit permissions</h3>
<div id="permissionEditorGroups"></div>
<div class="spaced-top">
<button id="savePermissions">Save Permissions</button>
</div>
</div>
</div>
<div id="createUserWizard" class="spaced-top" style="display:none;">
<h3 id="createWizardTitle">Create User - Step 1 of 3</h3>
<div id="wizardStep1" class="card spaced-top">
<label for="wizardUsername">Username</label>
<input id="wizardUsername" placeholder="Choose username">
</div>
<div id="wizardStep2" class="card spaced-top" style="display:none;">
<label for="wizardPassword">Password</label>
<input id="wizardPassword" type="password" placeholder="Choose password (min. 10 chars)">
</div>
<div id="wizardStep3" class="card spaced-top" style="display:none;">
<label for="wizardRole">Role Preset</label>
<select id="wizardRole">
<option>VIEWER</option>
<option>ADMIN</option>
</select>
<div id="wizardPermissionGroups" class="spaced-top"></div>
</div>
<div class="inline-form spaced-top">
<button id="wizardCancel" type="button" class="secondary">Cancel</button>
<button id="wizardBack" type="button" class="secondary">Back</button>
<button id="wizardNext" type="button">Next</button>
<button id="wizardCreate" type="button" style="display:none;">Create User</button>
</div>
</div>
<p id="userStatus" class="action-status"></p>
</section>
</main>
</div>
<script>
let permissionCatalog = [];
let roleDefaults = {};
let usersById = new Map();
let selectedUserId = null;
const permissionGroupStateKey = 'minepanel.permissions.groupState';
const createWizardState = {
step: 1,
username: '',
password: '',
role: 'VIEWER',
permissions: []
};
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
function setStatus(message, isError) {
const status = document.getElementById('userStatus');
status.textContent = message || '';
status.className = isError ? 'action-status error-text' : 'action-status success-text';
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function groupedPermissions() {
const grouped = new Map();
permissionCatalog.forEach(permission => {
const category = permission.category || 'Other';
if (!grouped.has(category)) grouped.set(category, []);
grouped.get(category).push(permission);
});
return grouped;
}
function loadPermissionGroupState() {
try {
return JSON.parse(sessionStorage.getItem(permissionGroupStateKey) || '{}') || {};
} catch (ignored) {
return {};
}
}
function savePermissionGroupState(state) {
try {
sessionStorage.setItem(permissionGroupStateKey, JSON.stringify(state));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
function renderPermissionCheckboxes(container, selectedPermissions, prefix, disabled) {
const groups = groupedPermissions();
const state = loadPermissionGroupState();
container.innerHTML = '';
container.classList.add('permissions-shell');
let groupIndex = 0;
groups.forEach((items, category) => {
const card = document.createElement('div');
card.className = 'permission-group';
const groupId = `${prefix}:${category}`;
const hasSavedState = Object.prototype.hasOwnProperty.call(state, groupId);
const isOpen = hasSavedState ? state[groupId] === true : groupIndex === 0;
const details = document.createElement('details');
details.open = isOpen;
details.addEventListener('toggle', () => {
state[groupId] = details.open;
savePermissionGroupState(state);
card.classList.toggle('open', details.open);
});
if (details.open) {
card.classList.add('open');
}
const toggle = document.createElement('summary');
toggle.className = 'permission-group-toggle';
toggle.innerHTML = `
<span>
<span class="permission-group-title">${category}</span>
<span class="permission-group-meta">${items.length} permission${items.length === 1 ? '' : 's'}</span>
</span>
<span class="permission-group-chevron">▼</span>
`;
const wrapper = document.createElement('div');
wrapper.className = 'permission-grid';
items.forEach(permission => {
const id = `${prefix}_${permission.key}`;
const checked = selectedPermissions.has(permission.key) ? 'checked' : '';
const lock = disabled ? 'disabled' : '';
const row = document.createElement('label');
row.className = `permission-row${disabled ? ' disabled' : ''}`;
row.innerHTML = `<input type="checkbox" id="${id}" data-permission="${permission.key}" ${checked} ${lock}><span>${permission.label}</span>`;
wrapper.appendChild(row);
});
details.appendChild(toggle);
details.appendChild(wrapper);
card.appendChild(details);
container.appendChild(card);
groupIndex++;
});
}
function selectedPermissionsFromContainer(container) {
const selected = [];
container.querySelectorAll('input[type="checkbox"][data-permission]').forEach(input => {
if (input.checked) selected.push(input.getAttribute('data-permission'));
});
return selected;
}
function defaultPermissionsForRole(role) {
return new Set(Array.isArray(roleDefaults[role]) ? roleDefaults[role] : []);
}
function setCreateWizardVisible(visible) {
document.getElementById('usersMainContent').style.display = visible ? 'none' : '';
document.getElementById('createUserWizard').style.display = visible ? '' : 'none';
}
function resetCreateWizard() {
createWizardState.step = 1;
createWizardState.username = '';
createWizardState.password = '';
createWizardState.role = 'VIEWER';
createWizardState.permissions = [];
document.getElementById('wizardUsername').value = '';
document.getElementById('wizardPassword').value = '';
document.getElementById('wizardRole').value = 'VIEWER';
renderCreateWizardStep();
}
function captureWizardPermissions() {
const groups = document.getElementById('wizardPermissionGroups');
if (groups && groups.children.length > 0) {
createWizardState.permissions = selectedPermissionsFromContainer(groups);
}
}
function renderCreateWizardPermissions(useRoleDefaults) {
if (useRoleDefaults || !Array.isArray(createWizardState.permissions) || createWizardState.permissions.length === 0) {
createWizardState.permissions = Array.from(defaultPermissionsForRole(createWizardState.role));
}
renderPermissionCheckboxes(
document.getElementById('wizardPermissionGroups'),
new Set(createWizardState.permissions),
'create_wizard',
false
);
}
function renderCreateWizardStep() {
const step = createWizardState.step;
document.getElementById('createWizardTitle').textContent = `Create User - Step ${step} of 3`;
document.getElementById('wizardStep1').style.display = step === 1 ? '' : 'none';
document.getElementById('wizardStep2').style.display = step === 2 ? '' : 'none';
document.getElementById('wizardStep3').style.display = step === 3 ? '' : 'none';
document.getElementById('wizardBack').style.display = step === 1 ? 'none' : '';
document.getElementById('wizardNext').style.display = step === 3 ? 'none' : '';
document.getElementById('wizardCreate').style.display = step === 3 ? '' : 'none';
if (step === 1) {
document.getElementById('wizardUsername').value = createWizardState.username;
} else if (step === 2) {
document.getElementById('wizardPassword').value = createWizardState.password;
} else if (step === 3) {
document.getElementById('wizardRole').value = createWizardState.role;
renderCreateWizardPermissions(false);
}
}
async function loadUsers() {
const data = await api('/api/users');
permissionCatalog = Array.isArray(data.permissions) ? data.permissions : [];
roleDefaults = data.roleDefaults || {};
usersById = new Map();
(Array.isArray(data.users) ? data.users : []).forEach(user => usersById.set(String(user.id), user));
const tbody = document.getElementById('users');
tbody.innerHTML = '';
data.users.forEach(user => {
const tr = document.createElement('tr');
const ownerLocked = !!user.isOwner;
tr.innerHTML = `<td>${user.id}</td><td>${user.username}</td><td>
<select data-id="${user.id}" ${ownerLocked ? 'disabled' : ''}>
${['VIEWER','ADMIN'].map(r => `<option ${user.role === r ? 'selected' : ''}>${r}</option>`).join('')}
</select>
</td><td><button data-save="${user.id}">Save</button></td>
<td><span class="muted">-</span></td>
<td><button data-delete="${user.id}" ${ownerLocked ? 'disabled title="Owner users cannot be deleted"' : ''}>Delete</button></td>`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('button[data-save]').forEach(button => {
button.addEventListener('click', async () => {
const id = button.getAttribute('data-save');
const user = usersById.get(id);
if (user && user.isOwner) {
setStatus('Owner role cannot be changed.', true);
return;
}
const role = tbody.querySelector(`select[data-id="${id}"]`).value;
try {
await api(`/api/users/${id}/role`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role })
});
await loadUsers();
setStatus('User role updated successfully.', false);
} catch (error) {
setStatus(`Failed to update user role: ${error.message}`, true);
}
});
});
tbody.querySelectorAll('tr').forEach(row => {
const id = row.querySelector('button[data-save]')?.getAttribute('data-save');
const user = id ? usersById.get(id) : null;
const permissionsCell = row.children[4];
if (!permissionsCell || !user) return;
const button = document.createElement('button');
button.type = 'button';
button.textContent = user.isOwner ? 'Owner (all permissions)' : 'Edit Permissions';
button.disabled = !!user.isOwner;
button.addEventListener('click', () => openPermissionEditor(user));
permissionsCell.innerHTML = '';
permissionsCell.appendChild(button);
});
tbody.querySelectorAll('button[data-delete]').forEach(button => {
button.addEventListener('click', async () => {
const id = button.getAttribute('data-delete');
const row = button.closest('tr');
const username = row ? row.children[1].textContent : `ID ${id}`;
if (!confirm(`Delete panel user '${username}'?`)) {
return;
}
try {
await api(`/api/users/${id}/delete`, {
method: 'POST'
});
await loadUsers();
setStatus(`Deleted user '${username}'.`, false);
} catch (error) {
setStatus(`Failed to delete user '${username}': ${error.message}`, true);
}
});
});
}
function openPermissionEditor(user) {
selectedUserId = String(user.id);
const editor = document.getElementById('permissionEditor');
const title = document.getElementById('permissionEditorTitle');
const groups = document.getElementById('permissionEditorGroups');
title.textContent = `Permissions: ${user.username}`;
const selected = new Set(Array.isArray(user.permissions) ? user.permissions : []);
renderPermissionCheckboxes(groups, selected, `edit_${user.id}`, false);
editor.style.display = 'block';
}
document.getElementById('logout').addEventListener('click', async () => {
try {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
} catch (error) {
setStatus(`Logout failed: ${error.message}`, true);
}
});
document.getElementById('openCreateWizard').addEventListener('click', () => {
resetCreateWizard();
setCreateWizardVisible(true);
});
document.getElementById('wizardCancel').addEventListener('click', () => {
captureWizardPermissions();
setCreateWizardVisible(false);
setStatus('', false);
});
document.getElementById('wizardBack').addEventListener('click', () => {
if (createWizardState.step === 3) {
captureWizardPermissions();
}
createWizardState.step = Math.max(1, createWizardState.step - 1);
renderCreateWizardStep();
});
document.getElementById('wizardNext').addEventListener('click', () => {
if (createWizardState.step === 1) {
const username = document.getElementById('wizardUsername').value.trim();
if (username.length < 3) {
setStatus('Username must be at least 3 characters.', true);
return;
}
createWizardState.username = username;
} else if (createWizardState.step === 2) {
const password = document.getElementById('wizardPassword').value;
if (password.length < 10) {
setStatus('Password must be at least 10 characters.', true);
return;
}
createWizardState.password = password;
createWizardState.permissions = Array.from(defaultPermissionsForRole(createWizardState.role));
}
createWizardState.step = Math.min(3, createWizardState.step + 1);
renderCreateWizardStep();
setStatus('', false);
});
document.getElementById('wizardRole').addEventListener('change', (event) => {
createWizardState.role = event.target.value;
renderCreateWizardPermissions(true);
});
document.getElementById('wizardCreate').addEventListener('click', async () => {
captureWizardPermissions();
try {
await api('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: createWizardState.username,
password: createWizardState.password,
role: createWizardState.role,
permissions: createWizardState.permissions
})
});
await loadUsers();
setCreateWizardVisible(false);
resetCreateWizard();
setStatus('User created successfully.', false);
} catch (error) {
setStatus(`Failed to create user: ${error.message}`, true);
}
});
document.getElementById('savePermissions').addEventListener('click', async () => {
if (!selectedUserId) {
setStatus('No user selected.', true);
return;
}
try {
const permissions = selectedPermissionsFromContainer(document.getElementById('permissionEditorGroups'));
await api(`/api/users/${selectedUserId}/permissions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permissions })
});
await loadUsers();
setStatus('Permissions updated successfully.', false);
} catch (error) {
setStatus(`Failed to update permissions: ${error.message}`, true);
}
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadUsers();
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
@@ -0,0 +1,305 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - Whitelist</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
<a class="side-link" href="/dashboard/extensions">Extensions</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>Whitelist</h1>
<p>View, add, and remove whitelisted players.</p>
<section class="card">
<h2>Whitelist Status</h2>
<div class="toolbar">
<strong id="whitelistStatus" style="align-self:center;min-width:180px;">Status: Unknown</strong>
<button id="enableWhitelistBtn">Enable Whitelist</button>
<button id="disableWhitelistBtn" class="secondary">Disable Whitelist</button>
</div>
</section>
<section class="card spaced-top">
<h2>Add Player</h2>
<div class="toolbar">
<input id="usernameInput" placeholder="Minecraft username">
<button id="addBtn">Add to Whitelist</button>
<button id="refreshBtn" class="secondary">Refresh</button>
</div>
<div id="statusMessage" class="warning" style="display:none;margin-top:10px;"></div>
</section>
<section class="card spaced-top">
<h2>Whitelisted Players (<span id="count">0</span>)</h2>
<table>
<thead>
<tr>
<th>Username</th>
<th>UUID</th>
<th>Online</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="whitelistBody"></tbody>
</table>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const nextExpanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', nextExpanded);
toggle.classList.toggle('expanded', nextExpanded);
savedState[category] = nextExpanded;
persistState();
});
});
}
async function api(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
const error = new Error(payload.error || 'Request failed');
error.status = response.status;
throw error;
}
return payload;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function showStatus(message, isError) {
const box = document.getElementById('statusMessage');
if (!message) {
box.style.display = 'none';
box.textContent = '';
return;
}
box.style.display = 'block';
box.textContent = message;
box.style.borderColor = isError ? 'rgba(231, 76, 60, 0.45)' : 'rgba(46, 204, 113, 0.45)';
box.style.background = isError ? 'rgba(231, 76, 60, 0.16)' : 'rgba(46, 204, 113, 0.14)';
box.style.color = isError ? '#ffc7c1' : '#b9f8cf';
}
function renderWhitelist(entries) {
const tbody = document.getElementById('whitelistBody');
tbody.innerHTML = '';
if (!Array.isArray(entries) || entries.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4">No whitelisted players found</td>';
tbody.appendChild(row);
return;
}
for (const entry of entries) {
const row = document.createElement('tr');
row.innerHTML = `
<td>${entry.username || '-'}</td>
<td>${entry.uuid || '-'}</td>
<td>${entry.online ? 'Yes' : 'No'}</td>
<td><button class="danger" data-remove="${entry.username || ''}">Remove</button></td>
`;
tbody.appendChild(row);
}
tbody.querySelectorAll('button[data-remove]').forEach(button => {
button.addEventListener('click', async () => {
const username = button.dataset.remove || '';
if (!username) {
return;
}
await removeFromWhitelist(username);
});
});
}
function applyWhitelistStatus(enabled) {
const label = document.getElementById('whitelistStatus');
const active = enabled === true;
label.textContent = `Status: ${active ? 'Enabled' : 'Disabled'}`;
label.style.color = active ? '#8af0b4' : '#ff9ca1';
}
async function loadWhitelistStatus() {
const data = await api('/api/extensions/whitelist/status');
applyWhitelistStatus(data.enabled === true);
}
async function setWhitelistEnabled(enabled) {
const data = await api('/api/extensions/whitelist/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enabled === true })
});
applyWhitelistStatus(data.enabled === true);
showStatus(`Whitelist ${data.enabled ? 'enabled' : 'disabled'}.`, false);
}
async function loadWhitelist() {
const data = await api('/api/extensions/whitelist');
const entries = Array.isArray(data.entries) ? data.entries : [];
document.getElementById('count').textContent = String(data.count || entries.length);
renderWhitelist(entries);
}
async function addToWhitelist() {
const input = document.getElementById('usernameInput');
const username = input.value.trim();
if (!username) {
showStatus('Please enter a username.', true);
return;
}
await api('/api/extensions/whitelist/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
input.value = '';
showStatus(`Added ${username} to whitelist.`, false);
await loadWhitelist();
}
async function removeFromWhitelist(username) {
await api('/api/extensions/whitelist/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
showStatus(`Removed ${username} from whitelist.`, false);
await loadWhitelist();
}
document.getElementById('addBtn').addEventListener('click', async () => {
try {
await addToWhitelist();
} catch (error) {
showStatus(error.message || 'Could not add player.', true);
}
});
document.getElementById('refreshBtn').addEventListener('click', async () => {
try {
await loadWhitelistStatus();
await loadWhitelist();
showStatus('', false);
} catch (error) {
showStatus(error.message || 'Could not refresh whitelist.', true);
}
});
document.getElementById('enableWhitelistBtn').addEventListener('click', async () => {
try {
await setWhitelistEnabled(true);
} catch (error) {
showStatus(error.message || 'Could not enable whitelist.', true);
}
});
document.getElementById('disableWhitelistBtn').addEventListener('click', async () => {
try {
await setWhitelistEnabled(false);
} catch (error) {
showStatus(error.message || 'Could not disable whitelist.', true);
}
});
document.getElementById('usernameInput').addEventListener('keydown', async event => {
if (event.key === 'Enter') {
event.preventDefault();
try {
await addToWhitelist();
} catch (error) {
showStatus(error.message || 'Could not add player.', true);
}
}
});
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadWhitelistStatus();
await loadWhitelist();
} catch (error) {
if (error && error.status === 401) {
window.location.href = '/';
return;
}
showStatus((error && error.message) ? error.message : 'Could not load whitelist page.', true);
console.error('Whitelist page load failed:', error);
}
})();
</script>
</body>
</html>
@@ -0,0 +1,289 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Dashboard - World Backups</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">MinePanel</div>
<div id="me" class="sidebar-meta"></div>
<nav class="side-nav">
<a class="side-link" href="/dashboard/overview">Overview</a>
<button class="side-category-toggle" data-category="server" type="button">Server</button>
<div class="side-category-items" data-category-items="server">
<a class="side-link" href="/console">Console</a>
<a class="side-link" href="/dashboard/resources">Resources</a>
<a class="side-link active" href="/dashboard/world-backups">Backups</a>
<a class="side-link" href="/dashboard/players">Players</a>
<a class="side-link" href="/dashboard/bans">Bans</a>
<a class="side-link" href="/dashboard/plugins">Plugins</a>
<a class="side-link" href="/dashboard/reports">Reports</a>
<a class="side-link" href="/dashboard/tickets">Tickets</a>
</div>
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
<div class="side-category-items" data-category-items="panel">
<a class="side-link" href="/dashboard/users">Users</a>
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
<a class="side-link" href="/dashboard/themes">Themes</a>
</div>
</nav>
<button id="logout" class="secondary sidebar-logout">Logout</button>
</aside>
<main class="content-area">
<h1>World Backups</h1>
<p>Create separate backups for Overworld, Nether and End. Backups are saved to <code>plugins/MinePanel/backups</code>.</p>
<section class="card">
<h2>Create Backup</h2>
<div class="toolbar">
<select id="backupWorld" style="width:auto;min-width:180px;">
<option value="overworld">Overworld</option>
<option value="nether">Nether</option>
<option value="end">End</option>
</select>
<input id="backupName" placeholder="Backup name (example: before-update)">
<button id="createBackup">Create Backup</button>
</div>
<p id="backupStatus" class="status-line"></p>
</section>
<section class="card spaced-top">
<h2>Overworld Backups</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>File</th>
<th>Created</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="overworldBackups"></tbody>
</table>
</section>
<section class="card spaced-top">
<h2>Nether Backups</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>File</th>
<th>Created</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="netherBackups"></tbody>
</table>
</section>
<section class="card spaced-top">
<h2>End Backups</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>File</th>
<th>Created</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="endBackups"></tbody>
</table>
</section>
</main>
</div>
<script>
function initSidebarCategories() {
const storageKey = 'minepanel.sidebar.categories';
let savedState = {};
try {
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
} catch (ignored) {
savedState = {};
}
function persistState() {
try {
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
} catch (ignored) {
}
}
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
const category = toggle.dataset.category;
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (!items || !category) return;
const expanded = savedState[category] === true;
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
toggle.addEventListener('click', () => {
const expanded = !items.classList.contains('expanded');
items.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
savedState[category] = expanded;
persistState();
});
});
}
async function api(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
const detail = payload && payload.details ? ` (${payload.details})` : '';
throw new Error(`${payload.error || 'request_failed'}${detail}`);
}
return payload;
}
async function loadMe() {
const data = await api('/api/me');
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
}
function formatDate(value) {
if (!value || Number(value) <= 0) {
return '-';
}
return new Date(Number(value)).toLocaleString();
}
function formatSize(bytes) {
const value = Number(bytes) || 0;
if (value < 1024) return `${value} B`;
const kb = value / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
const gb = mb / 1024;
return `${gb.toFixed(2)} GB`;
}
function renderBackupList(world, backups) {
const body = document.getElementById(`${world}Backups`);
body.innerHTML = '';
if (!Array.isArray(backups) || backups.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 5;
cell.textContent = 'No backups yet';
row.appendChild(cell);
body.appendChild(row);
return;
}
for (const backup of backups) {
const row = document.createElement('tr');
row.innerHTML = `
<td>${backup.name || '-'}</td>
<td>${backup.fileName || '-'}</td>
<td>${formatDate(backup.createdAt)}</td>
<td>${formatSize(backup.sizeBytes)}</td>
<td></td>
`;
const actionsCell = row.lastElementChild;
const deleteButton = document.createElement('button');
deleteButton.className = 'secondary';
deleteButton.style.width = 'auto';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', async () => {
await deleteBackup(world, backup.fileName);
});
actionsCell.appendChild(deleteButton);
body.appendChild(row);
}
}
async function deleteBackup(world, fileName) {
const status = document.getElementById('backupStatus');
if (!fileName) {
status.textContent = 'Delete failed: invalid file name.';
return;
}
const confirmed = window.confirm(`Delete backup ${fileName}?`);
if (!confirmed) {
return;
}
status.textContent = `Deleting backup ${fileName}...`;
try {
await api('/api/extensions/world-backups/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ world, fileName })
});
status.textContent = `Deleted backup ${fileName}.`;
await loadBackups();
} catch (error) {
status.textContent = `Delete failed: ${error.message}`;
}
}
async function loadBackups() {
const data = await api('/api/extensions/world-backups');
renderBackupList('overworld', data.overworld || []);
renderBackupList('nether', data.nether || []);
renderBackupList('end', data.end || []);
}
async function createBackup() {
const world = document.getElementById('backupWorld').value;
const name = document.getElementById('backupName').value.trim();
const status = document.getElementById('backupStatus');
if (!name) {
status.textContent = 'Please enter a backup name.';
return;
}
status.textContent = 'Creating backup...';
try {
await api('/api/extensions/world-backups/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ world, name })
});
status.textContent = 'Backup created successfully.';
document.getElementById('backupName').value = '';
await loadBackups();
} catch (error) {
status.textContent = `Backup failed: ${error.message}`;
}
}
document.getElementById('createBackup').addEventListener('click', createBackup);
document.getElementById('logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' });
window.location.href = '/';
});
(async () => {
try {
initSidebarCategories();
await loadMe();
await loadBackups();
} catch {
window.location.href = '/';
}
})();
</script>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MinePanel Login</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<main class="page-center">
<section class="card stack">
<h1>MinePanel</h1>
<p>Sign in to access logs and admin tools.</p>
<div id="error" class="error"></div>
<div id="oauthActions" class="stack"></div>
<div class="label">Username</div>
<input id="username" placeholder="Username" autocomplete="username">
<div class="label">Password</div>
<input id="password" type="password" placeholder="Password" autocomplete="current-password">
<button id="login">Login</button>
</section>
</main>
<script>
const error = document.getElementById('error');
function showOAuthQueryState() {
const url = new URL(window.location.href);
const oauth = (url.searchParams.get('oauth') || '').trim();
if (!oauth) {
return;
}
const friendly = {
not_linked: 'This OAuth account is not linked to a panel user.',
invalid_state: 'OAuth session expired. Please try again.',
exchange_failed: 'OAuth login failed. Please try again.',
provider_not_configured: 'OAuth provider is not configured on this server.',
user_not_found: 'The linked panel account no longer exists.'
};
error.textContent = friendly[oauth] || `OAuth login failed: ${oauth}`;
url.searchParams.delete('oauth');
window.history.replaceState({}, document.title, url.pathname + (url.search ? `?${url.searchParams.toString()}` : ''));
}
async function api(url, options) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Request failed');
}
return data;
}
async function loadOAuthButtons() {
const container = document.getElementById('oauthActions');
try {
const data = await api('/api/oauth/providers');
const providers = Array.isArray(data.providers) ? data.providers : [];
const available = providers.filter(provider => provider && provider.enabled && provider.configured);
if (available.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = available
.map(provider => `<button class="secondary" data-oauth="${provider.provider}">Continue with ${provider.label}</button>`)
.join('');
container.querySelectorAll('button[data-oauth]').forEach(button => {
button.addEventListener('click', async () => {
const provider = button.getAttribute('data-oauth');
error.textContent = '';
try {
const start = await api(`/api/oauth/${provider}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'login' })
});
window.location.href = start.authUrl;
} catch (startError) {
error.textContent = startError.message || 'Could not start OAuth login';
}
});
});
} catch {
container.innerHTML = '';
}
}
document.getElementById('login').addEventListener('click', async () => {
error.textContent = '';
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value
})
});
const data = await res.json();
if (!res.ok) {
error.textContent = data.error || 'Login failed';
return;
}
window.location.href = '/dashboard/overview';
});
showOAuthQueryState();
loadOAuthButtons();
</script>
</body>
</html>
+968
View File
@@ -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%;
}
}
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Panel Setup</title>
<link rel="stylesheet" href="/panel.css">
<script src="/theme.js"></script>
</head>
<body>
<main class="page-center">
<section class="card stack">
<h1>First Launch Setup</h1>
<p>Use the bootstrap token printed in the server console to create the owner account.</p>
<div id="error" class="error"></div>
<div class="label">Bootstrap Token</div>
<input id="token" placeholder="Bootstrap token">
<div class="label">Owner Username</div>
<input id="username" placeholder="Owner username">
<div class="label">Owner Password</div>
<input id="password" type="password" placeholder="Owner password (min 10 chars)">
<button id="create">Create Owner Account</button>
</section>
</main>
<script>
const error = document.getElementById('error');
document.getElementById('create').addEventListener('click', async () => {
error.textContent = '';
const res = await fetch('/api/bootstrap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: document.getElementById('token').value,
username: document.getElementById('username').value,
password: document.getElementById('password').value
})
});
const data = await res.json();
if (!res.ok) {
error.textContent = data.error || 'Setup failed';
return;
}
window.location.href = '/dashboard/overview';
});
</script>
</body>
</html>
+486
View File
@@ -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();
})();