first commit
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
package de.winniepat.minePanel;
|
||||
|
||||
import de.winniepat.minePanel.auth.*;
|
||||
import de.winniepat.minePanel.config.WebPanelConfig;
|
||||
import de.winniepat.minePanel.extensions.*;
|
||||
import de.winniepat.minePanel.integrations.*;
|
||||
import de.winniepat.minePanel.lifecycle.PluginLifecycleSupport;
|
||||
import de.winniepat.minePanel.logs.*;
|
||||
import de.winniepat.minePanel.persistence.*;
|
||||
import de.winniepat.minePanel.util.ServerSchedulerBridge;
|
||||
import de.winniepat.minePanel.web.*;
|
||||
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
|
||||
public final class MinePanel extends JavaPlugin {
|
||||
|
||||
private Database database;
|
||||
private WebPanelServer webPanelServer;
|
||||
private DiscordWebhookService discordWebhookService;
|
||||
private LogRepository logRepository;
|
||||
private PanelLogger panelLogger;
|
||||
private PlayerActivityRepository playerActivityRepository;
|
||||
private JoinLeaveEventRepository joinLeaveEventRepository;
|
||||
private ExtensionManager extensionManager;
|
||||
private ExtensionCommandRegistry extensionCommandRegistry;
|
||||
private WebAssetService webAssetService;
|
||||
private ExtensionSettingsRepository extensionSettingsRepository;
|
||||
private ServerSchedulerBridge schedulerBridge;
|
||||
|
||||
MiniMessage mm = MiniMessage.miniMessage();
|
||||
ComponentLogger componentLogger = this.getComponentLogger();
|
||||
|
||||
private record StartupContext(
|
||||
UserRepository userRepository,
|
||||
KnownPlayerRepository knownPlayerRepository,
|
||||
SessionService sessionService,
|
||||
PasswordHasher passwordHasher,
|
||||
ServerLogService serverLogService,
|
||||
BootstrapService bootstrapService,
|
||||
JoinLeaveEventRepository joinLeaveEventRepository,
|
||||
OAuthAccountRepository oAuthAccountRepository,
|
||||
OAuthStateRepository oAuthStateRepository,
|
||||
ExtensionManager extensionManager,
|
||||
WebAssetService webAssetService,
|
||||
ExtensionSettingsRepository extensionSettingsRepository
|
||||
) {}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
saveDefaultConfig();
|
||||
PluginLifecycleSupport.configureThirdPartyStartupLogging();
|
||||
this.schedulerBridge = new ServerSchedulerBridge(this);
|
||||
|
||||
WebPanelConfig panelConfig = WebPanelConfig.fromConfig(getConfig());
|
||||
StartupContext startupContext = initializeStartupContext(panelConfig);
|
||||
|
||||
PluginLifecycleSupport.announceMinePanelBanner(this, componentLogger, mm);
|
||||
PluginLifecycleSupport.announceBootstrapToken(startupContext.bootstrapService(), componentLogger, mm);
|
||||
|
||||
PluginLifecycleSupport.registerPluginListeners(
|
||||
this,
|
||||
panelLogger,
|
||||
startupContext.knownPlayerRepository(),
|
||||
playerActivityRepository,
|
||||
joinLeaveEventRepository
|
||||
);
|
||||
PluginLifecycleSupport.synchronizeKnownPlayers(getServer(), startupContext.knownPlayerRepository(), playerActivityRepository);
|
||||
startWebPanel(panelConfig, startupContext);
|
||||
|
||||
componentLogger.info("{}{}:{}", mm.deserialize("<aqua>MinePanel available at: </aqua>"), "http://" + panelConfig.host(), panelConfig.port());
|
||||
panelLogger.log("SYSTEM", "PLUGIN", "MinePanel plugin started");
|
||||
}
|
||||
|
||||
private StartupContext initializeStartupContext(WebPanelConfig panelConfig) {
|
||||
this.database = new Database(getDataFolder().toPath().resolve("panel.db"));
|
||||
this.database.initialize();
|
||||
|
||||
UserRepository userRepository = new UserRepository(database);
|
||||
int normalizedOwners = userRepository.demoteExtraOwnersToAdmin();
|
||||
if (normalizedOwners > 0) {
|
||||
getLogger().warning("Detected multiple owner accounts. Demoted " + normalizedOwners + " extra owner account(s) to ADMIN.");
|
||||
}
|
||||
|
||||
this.logRepository = new LogRepository(database);
|
||||
KnownPlayerRepository knownPlayerRepository = new KnownPlayerRepository(database);
|
||||
this.playerActivityRepository = new PlayerActivityRepository(database);
|
||||
this.joinLeaveEventRepository = new JoinLeaveEventRepository(database);
|
||||
|
||||
DiscordWebhookRepository discordWebhookRepository = new DiscordWebhookRepository(database);
|
||||
this.discordWebhookService = new DiscordWebhookService(getLogger(), discordWebhookRepository);
|
||||
this.panelLogger = new PanelLogger(logRepository, discordWebhookService);
|
||||
this.logRepository.clearLogs();
|
||||
|
||||
SessionService sessionService = new SessionService(database, panelConfig.sessionTtlMinutes());
|
||||
PasswordHasher passwordHasher = new PasswordHasher();
|
||||
ServerLogService serverLogService = new ServerLogService(getDataFolder().toPath());
|
||||
BootstrapService bootstrapService = new BootstrapService(userRepository, panelConfig.bootstrapTokenLength());
|
||||
OAuthAccountRepository oAuthAccountRepository = new OAuthAccountRepository(database);
|
||||
OAuthStateRepository oAuthStateRepository = new OAuthStateRepository(database);
|
||||
this.extensionSettingsRepository = new ExtensionSettingsRepository(database);
|
||||
this.webAssetService = initializeWebAssets();
|
||||
|
||||
this.extensionManager = initializeExtensions(knownPlayerRepository);
|
||||
|
||||
return new StartupContext(
|
||||
userRepository,
|
||||
knownPlayerRepository,
|
||||
sessionService,
|
||||
passwordHasher,
|
||||
serverLogService,
|
||||
bootstrapService,
|
||||
joinLeaveEventRepository,
|
||||
oAuthAccountRepository,
|
||||
oAuthStateRepository,
|
||||
extensionManager,
|
||||
webAssetService,
|
||||
extensionSettingsRepository
|
||||
);
|
||||
}
|
||||
|
||||
private WebAssetService initializeWebAssets() {
|
||||
WebAssetService service = new WebAssetService(this, getDataFolder().toPath().resolve("web"));
|
||||
service.ensureSeeded();
|
||||
return service;
|
||||
}
|
||||
|
||||
private ExtensionManager initializeExtensions(KnownPlayerRepository knownPlayerRepository) {
|
||||
this.extensionCommandRegistry = new BukkitExtensionCommandRegistry(this);
|
||||
ExtensionContext context = new ExtensionContext(
|
||||
this,
|
||||
database,
|
||||
panelLogger,
|
||||
knownPlayerRepository,
|
||||
playerActivityRepository,
|
||||
extensionCommandRegistry,
|
||||
schedulerBridge
|
||||
);
|
||||
ExtensionManager manager = new ExtensionManager(this, context);
|
||||
|
||||
|
||||
Path extensionDirectory = getDataFolder().toPath().resolve("extensions");
|
||||
manager.loadFromDirectory(extensionDirectory);
|
||||
manager.enableAll();
|
||||
return manager;
|
||||
}
|
||||
|
||||
public ServerSchedulerBridge schedulerBridge() {
|
||||
return schedulerBridge;
|
||||
}
|
||||
|
||||
private void startWebPanel(WebPanelConfig panelConfig, StartupContext startupContext) {
|
||||
this.webPanelServer = new WebPanelServer(
|
||||
this,
|
||||
panelConfig,
|
||||
startupContext.userRepository(),
|
||||
startupContext.sessionService(),
|
||||
startupContext.passwordHasher(),
|
||||
this.logRepository,
|
||||
startupContext.knownPlayerRepository(),
|
||||
playerActivityRepository,
|
||||
discordWebhookService,
|
||||
panelLogger,
|
||||
startupContext.serverLogService(),
|
||||
startupContext.bootstrapService(),
|
||||
startupContext.joinLeaveEventRepository(),
|
||||
startupContext.oAuthAccountRepository(),
|
||||
startupContext.oAuthStateRepository(),
|
||||
startupContext.extensionManager(),
|
||||
startupContext.webAssetService(),
|
||||
startupContext.extensionSettingsRepository()
|
||||
);
|
||||
this.webPanelServer.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (webPanelServer != null) {
|
||||
webPanelServer.stop();
|
||||
}
|
||||
|
||||
if (extensionManager != null) {
|
||||
extensionManager.disableAll();
|
||||
}
|
||||
|
||||
if (playerActivityRepository != null) {
|
||||
long now = Instant.now().toEpochMilli();
|
||||
getServer().getOnlinePlayers().forEach(player ->
|
||||
playerActivityRepository.onQuit(player.getUniqueId(), now)
|
||||
);
|
||||
}
|
||||
|
||||
if (panelLogger != null) {
|
||||
panelLogger.log("SYSTEM", "PLUGIN", "MinePanel plugin stopping");
|
||||
}
|
||||
|
||||
if (logRepository != null) {
|
||||
PluginLifecycleSupport.exportPanelLogsToServerLogsDirectory(getDataFolder().toPath(), logRepository, getLogger());
|
||||
}
|
||||
|
||||
if (discordWebhookService != null) {
|
||||
discordWebhookService.shutdown();
|
||||
}
|
||||
if (database != null) {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.winniepat.minePanel.auth;
|
||||
|
||||
import org.mindrot.jbcrypt.BCrypt;
|
||||
|
||||
public final class PasswordHasher {
|
||||
|
||||
public String hash(String rawPassword) {
|
||||
return BCrypt.hashpw(rawPassword, BCrypt.gensalt(12));
|
||||
}
|
||||
|
||||
public boolean verify(String rawPassword, String hashedPassword) {
|
||||
return BCrypt.checkpw(rawPassword, hashedPassword);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package de.winniepat.minePanel.auth;
|
||||
|
||||
import de.winniepat.minePanel.persistence.Database;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.sql.*;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
public final class SessionService {
|
||||
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private final Database database;
|
||||
private final int sessionTtlMinutes;
|
||||
|
||||
public SessionService(Database database, int sessionTtlMinutes) {
|
||||
this.database = database;
|
||||
this.sessionTtlMinutes = sessionTtlMinutes;
|
||||
}
|
||||
|
||||
public String createSession(long userId) {
|
||||
cleanupExpiredSessions();
|
||||
|
||||
String token = generateToken();
|
||||
long now = Instant.now().toEpochMilli();
|
||||
long expiresAt = now + (sessionTtlMinutes * 60_000L);
|
||||
|
||||
String sql = "INSERT INTO sessions(token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, token);
|
||||
statement.setLong(2, userId);
|
||||
statement.setLong(3, now);
|
||||
statement.setLong(4, expiresAt);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not create session", exception);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
public Optional<Long> resolveUserId(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
long now = Instant.now().toEpochMilli();
|
||||
String sql = "SELECT user_id FROM sessions WHERE token = ? AND expires_at > ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, token);
|
||||
statement.setLong(2, now);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return Optional.of(resultSet.getLong("user_id"));
|
||||
}
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not resolve session", exception);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public void deleteSession(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String sql = "DELETE FROM sessions WHERE token = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, token);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not delete session", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteSessionsByUserId(long userId) {
|
||||
String sql = "DELETE FROM sessions WHERE user_id = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, userId);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not delete sessions for user", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void cleanupExpiredSessions() {
|
||||
String sql = "DELETE FROM sessions WHERE expires_at <= ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, Instant.now().toEpochMilli());
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not clean expired sessions", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String generateToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
SECURE_RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.winniepat.minePanel.config;
|
||||
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
|
||||
public record WebPanelConfig(
|
||||
String host,
|
||||
int port,
|
||||
int sessionTtlMinutes,
|
||||
int bootstrapTokenLength,
|
||||
int oauthStateTtlMinutes,
|
||||
OAuthProviderConfig googleOAuth,
|
||||
OAuthProviderConfig discordOAuth
|
||||
) {
|
||||
|
||||
public static WebPanelConfig fromConfig(FileConfiguration config) {
|
||||
String host = config.getString("web.host", "127.0.0.1");
|
||||
int port = config.getInt("web.port", 8080);
|
||||
int sessionTtlMinutes = Math.max(5, config.getInt("web.sessionTtlMinutes", 120));
|
||||
int bootstrapTokenLength = Math.max(16, config.getInt("security.bootstrapTokenLength", 32));
|
||||
int oauthStateTtlMinutes = Math.max(2, config.getInt("integrations.oauth.stateTtlMinutes", 10));
|
||||
|
||||
OAuthProviderConfig googleOAuth = parseProvider(config, "google", "MINEPANEL_OAUTH_GOOGLE");
|
||||
OAuthProviderConfig discordOAuth = parseProvider(config, "discord", "MINEPANEL_OAUTH_DISCORD");
|
||||
|
||||
return new WebPanelConfig(host, port, sessionTtlMinutes, bootstrapTokenLength, oauthStateTtlMinutes, googleOAuth, discordOAuth);
|
||||
}
|
||||
|
||||
private static OAuthProviderConfig parseProvider(FileConfiguration config, String key, String envPrefix) {
|
||||
String section = "integrations.oauth." + key + ".";
|
||||
boolean enabled = config.getBoolean(section + "enabled", false);
|
||||
String clientId = envOrDefault(envPrefix + "_CLIENT_ID", config.getString(section + "clientId", ""));
|
||||
String clientSecret = envOrDefault(envPrefix + "_CLIENT_SECRET", config.getString(section + "clientSecret", ""));
|
||||
String redirectUri = envOrDefault(envPrefix + "_REDIRECT_URI", config.getString(section + "redirectUri", ""));
|
||||
return new OAuthProviderConfig(enabled, sanitize(clientId), sanitize(clientSecret), sanitize(redirectUri));
|
||||
}
|
||||
|
||||
private static String envOrDefault(String key, String fallback) {
|
||||
String value = System.getenv(key);
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static String sanitize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
public OAuthProviderConfig oauthProvider(String provider) {
|
||||
if (provider == null) {
|
||||
return OAuthProviderConfig.disabled();
|
||||
}
|
||||
String normalized = provider.trim().toLowerCase();
|
||||
if ("google".equals(normalized)) {
|
||||
return googleOAuth;
|
||||
}
|
||||
if ("discord".equals(normalized)) {
|
||||
return discordOAuth;
|
||||
}
|
||||
return OAuthProviderConfig.disabled();
|
||||
}
|
||||
|
||||
public record OAuthProviderConfig(boolean enabled, String clientId, String clientSecret, String redirectUri) {
|
||||
public static OAuthProviderConfig disabled() {
|
||||
return new OAuthProviderConfig(false, "", "", "");
|
||||
}
|
||||
|
||||
public boolean configured() {
|
||||
return enabled && !clientId.isBlank() && !clientSecret.isBlank() && !redirectUri.isBlank();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package de.winniepat.minePanel.extensions;
|
||||
|
||||
import de.winniepat.minePanel.MinePanel;
|
||||
import org.bukkit.command.*;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.command.PluginIdentifiableCommand;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.*;
|
||||
|
||||
public final class BukkitExtensionCommandRegistry implements ExtensionCommandRegistry {
|
||||
|
||||
private final MinePanel plugin;
|
||||
private final Map<String, List<RuntimeExtensionCommand>> commandsByExtensionId = new HashMap<>();
|
||||
|
||||
public BukkitExtensionCommandRegistry(MinePanel plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean register(
|
||||
String extensionId,
|
||||
String name,
|
||||
String description,
|
||||
String usage,
|
||||
String permission,
|
||||
List<String> aliases,
|
||||
CommandExecutor executor,
|
||||
TabCompleter tabCompleter
|
||||
) {
|
||||
if (executor == null || isBlank(extensionId) || isBlank(name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalizedExtensionId = extensionId.trim().toLowerCase(Locale.ROOT);
|
||||
String normalizedName = name.trim().toLowerCase(Locale.ROOT);
|
||||
|
||||
CommandMap commandMap = getCommandMap();
|
||||
if (commandMap == null) {
|
||||
plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command map unavailable");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (commandMap.getCommand(normalizedName) != null) {
|
||||
plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command already exists");
|
||||
return false;
|
||||
}
|
||||
|
||||
RuntimeExtensionCommand command = new RuntimeExtensionCommand(
|
||||
plugin,
|
||||
normalizedName,
|
||||
defaultString(description, "Extension command"),
|
||||
defaultString(usage, "/" + normalizedName),
|
||||
aliases == null ? List.of() : aliases,
|
||||
executor,
|
||||
tabCompleter
|
||||
);
|
||||
if (!isBlank(permission)) {
|
||||
command.setPermission(permission.trim());
|
||||
}
|
||||
|
||||
boolean registered = commandMap.register("minepanelext", command);
|
||||
if (!registered) {
|
||||
plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command map rejected registration");
|
||||
return false;
|
||||
}
|
||||
|
||||
commandsByExtensionId.computeIfAbsent(normalizedExtensionId, ignored -> new ArrayList<>()).add(command);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void unregisterForExtension(String extensionId) {
|
||||
if (isBlank(extensionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String normalizedExtensionId = extensionId.trim().toLowerCase(Locale.ROOT);
|
||||
List<RuntimeExtensionCommand> commands = commandsByExtensionId.remove(normalizedExtensionId);
|
||||
if (commands == null || commands.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
CommandMap commandMap = getCommandMap();
|
||||
if (commandMap == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Command> knownCommands = getKnownCommands(commandMap);
|
||||
for (RuntimeExtensionCommand command : commands) {
|
||||
command.unregister(commandMap);
|
||||
removeCommandEntries(knownCommands, command);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void unregisterAll() {
|
||||
List<String> extensionIds = new ArrayList<>(commandsByExtensionId.keySet());
|
||||
for (String extensionId : extensionIds) {
|
||||
unregisterForExtension(extensionId);
|
||||
}
|
||||
commandsByExtensionId.clear();
|
||||
}
|
||||
|
||||
private void removeCommandEntries(Map<String, Command> knownCommands, RuntimeExtensionCommand command) {
|
||||
if (knownCommands == null || knownCommands.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Iterator<Map.Entry<String, Command>> iterator = knownCommands.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Command> entry = iterator.next();
|
||||
if (entry.getValue() == command) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Command> getKnownCommands(CommandMap commandMap) {
|
||||
try {
|
||||
Field knownCommandsField = commandMap.getClass().getDeclaredField("knownCommands");
|
||||
knownCommandsField.setAccessible(true);
|
||||
Object value = knownCommandsField.get(commandMap);
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
return (Map<String, Command>) map;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Best effort cleanup.
|
||||
}
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
private CommandMap getCommandMap() {
|
||||
try {
|
||||
Method getCommandMapMethod = plugin.getServer().getClass().getMethod("getCommandMap");
|
||||
Object value = getCommandMapMethod.invoke(plugin.getServer());
|
||||
if (value instanceof CommandMap map) {
|
||||
return map;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Supported on CraftServer-based implementations.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String defaultString(String value, String fallback) {
|
||||
return isBlank(value) ? fallback : value.trim();
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static final class RuntimeExtensionCommand extends Command implements PluginIdentifiableCommand {
|
||||
|
||||
private final Plugin plugin;
|
||||
private final CommandExecutor executor;
|
||||
private final TabCompleter tabCompleter;
|
||||
|
||||
private RuntimeExtensionCommand(
|
||||
Plugin plugin,
|
||||
String name,
|
||||
String description,
|
||||
String usage,
|
||||
List<String> aliases,
|
||||
CommandExecutor executor,
|
||||
TabCompleter tabCompleter
|
||||
) {
|
||||
super(name, description, usage, aliases == null ? List.of() : aliases);
|
||||
this.plugin = plugin;
|
||||
this.executor = executor;
|
||||
this.tabCompleter = tabCompleter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean execute(CommandSender sender, String commandLabel, String[] args) {
|
||||
if (testPermission(sender)) {
|
||||
return executor.onCommand(sender, this, commandLabel, args);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> tabComplete(CommandSender sender, String alias, String[] args) {
|
||||
if (tabCompleter == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> completions = tabCompleter.onTabComplete(sender, this, alias, args);
|
||||
return completions == null ? List.of() : completions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Plugin getPlugin() {
|
||||
return plugin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.winniepat.minePanel.extensions;
|
||||
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ExtensionCommandRegistry {
|
||||
|
||||
boolean register(
|
||||
String extensionId,
|
||||
String name,
|
||||
String description,
|
||||
String usage,
|
||||
String permission,
|
||||
List<String> aliases,
|
||||
CommandExecutor executor,
|
||||
TabCompleter tabCompleter
|
||||
);
|
||||
|
||||
default boolean register(
|
||||
String extensionId,
|
||||
String name,
|
||||
String description,
|
||||
String usage,
|
||||
String permission,
|
||||
List<String> aliases,
|
||||
CommandExecutor executor
|
||||
) {
|
||||
return register(extensionId, name, description, usage, permission, aliases, executor, null);
|
||||
}
|
||||
|
||||
void unregisterForExtension(String extensionId);
|
||||
|
||||
void unregisterAll();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.winniepat.minePanel.extensions;
|
||||
|
||||
/**
|
||||
* Optional extension contract for runtime settings updates from the panel.
|
||||
*/
|
||||
public interface ExtensionConfigurable {
|
||||
|
||||
/**
|
||||
* Applies updated settings JSON immediately at runtime.
|
||||
*
|
||||
* @param settingsJson extension settings JSON payload
|
||||
*/
|
||||
void onSettingsUpdated(String settingsJson);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.winniepat.minePanel.extensions;
|
||||
|
||||
import de.winniepat.minePanel.MinePanel;
|
||||
import de.winniepat.minePanel.logs.PanelLogger;
|
||||
import de.winniepat.minePanel.persistence.Database;
|
||||
import de.winniepat.minePanel.persistence.KnownPlayerRepository;
|
||||
import de.winniepat.minePanel.persistence.PlayerActivityRepository;
|
||||
import de.winniepat.minePanel.util.ServerSchedulerBridge;
|
||||
|
||||
public record ExtensionContext(
|
||||
MinePanel plugin,
|
||||
Database database,
|
||||
PanelLogger panelLogger,
|
||||
KnownPlayerRepository knownPlayerRepository,
|
||||
PlayerActivityRepository playerActivityRepository,
|
||||
ExtensionCommandRegistry commandRegistry,
|
||||
ServerSchedulerBridge schedulerBridge
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
package de.winniepat.minePanel.extensions;
|
||||
|
||||
import de.winniepat.minePanel.MinePanel;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class ExtensionManager {
|
||||
|
||||
private final MinePanel plugin;
|
||||
private final ExtensionContext context;
|
||||
private final List<MinePanelExtension> loadedExtensions = new ArrayList<>();
|
||||
private final List<URLClassLoader> classLoaders = new ArrayList<>();
|
||||
private final Set<String> loadedIds = new HashSet<>();
|
||||
private final Set<String> loadedArtifacts = new HashSet<>();
|
||||
private final Map<String, String> extensionSourceById = new HashMap<>();
|
||||
private Path extensionsDirectory;
|
||||
private ExtensionWebRegistry activeWebRegistry;
|
||||
|
||||
public ExtensionManager(MinePanel plugin, ExtensionContext context) {
|
||||
this.plugin = plugin;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public void registerBuiltIn(MinePanelExtension extension) {
|
||||
registerLoadedExtension(extension, "built-in");
|
||||
}
|
||||
|
||||
public synchronized void loadFromDirectory(Path extensionsDirectory) {
|
||||
this.extensionsDirectory = extensionsDirectory;
|
||||
try {
|
||||
Files.createDirectories(extensionsDirectory);
|
||||
} catch (IOException exception) {
|
||||
plugin.getLogger().warning("Could not create extensions directory: " + exception.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupDuplicateArtifactsOnStartup(extensionsDirectory);
|
||||
|
||||
try (Stream<Path> files = Files.list(extensionsDirectory)) {
|
||||
files
|
||||
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
|
||||
.sorted(this::compareJarsNewestFirst)
|
||||
.forEach(path -> loadJarExtensions(path, null));
|
||||
} catch (IOException exception) {
|
||||
plugin.getLogger().warning("Could not scan extensions directory: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized ReloadResult reloadNewFromDirectory(ExtensionWebRegistry webRegistry) {
|
||||
if (webRegistry != null) {
|
||||
this.activeWebRegistry = webRegistry;
|
||||
}
|
||||
|
||||
if (extensionsDirectory == null) {
|
||||
return new ReloadResult(0, List.of(), List.of("extensions_directory_unavailable"));
|
||||
}
|
||||
|
||||
List<MinePanelExtension> newlyLoaded = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
int scanned = 0;
|
||||
|
||||
try (Stream<Path> files = Files.list(extensionsDirectory)) {
|
||||
List<Path> jars = files
|
||||
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
|
||||
.sorted(this::compareJarsNewestFirst)
|
||||
.toList();
|
||||
|
||||
for (Path jarFile : jars) {
|
||||
scanned++;
|
||||
String artifactName = jarFile.getFileName().toString();
|
||||
if (loadedArtifacts.contains(artifactName)) {
|
||||
continue;
|
||||
}
|
||||
loadJarExtensions(jarFile, newlyLoaded);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
warnings.add("could_not_scan_extensions_directory");
|
||||
plugin.getLogger().warning("Could not scan extensions directory: " + exception.getMessage());
|
||||
}
|
||||
|
||||
for (MinePanelExtension extension : newlyLoaded) {
|
||||
try {
|
||||
extension.onEnable();
|
||||
plugin.getLogger().info("Enabled extension " + extension.id() + " (" + extension.displayName() + ")");
|
||||
} catch (Exception exception) {
|
||||
warnings.add("enable_failed:" + extension.id());
|
||||
context.commandRegistry().unregisterForExtension(extension.id());
|
||||
plugin.getLogger().warning("Could not enable extension " + extension.id() + ": " + exception.getMessage());
|
||||
}
|
||||
|
||||
if (activeWebRegistry != null) {
|
||||
try {
|
||||
extension.registerWebRoutes(activeWebRegistry);
|
||||
} catch (Exception exception) {
|
||||
warnings.add("route_registration_failed:" + extension.id());
|
||||
plugin.getLogger().warning("Could not register web routes for extension " + extension.id() + ": " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String> loadedExtensionIds = newlyLoaded.stream().map(MinePanelExtension::id).toList();
|
||||
return new ReloadResult(scanned, loadedExtensionIds, warnings);
|
||||
}
|
||||
|
||||
public synchronized List<Map<String, Object>> installedExtensions() {
|
||||
List<Map<String, Object>> installed = new ArrayList<>();
|
||||
for (MinePanelExtension extension : loadedExtensions) {
|
||||
String id = extension.id() == null ? "" : extension.id();
|
||||
String source = extensionSourceById.getOrDefault(id.toLowerCase(Locale.ROOT), "unknown");
|
||||
installed.add(Map.of(
|
||||
"id", id,
|
||||
"displayName", extension.displayName() == null ? id : extension.displayName(),
|
||||
"source", source
|
||||
));
|
||||
}
|
||||
|
||||
installed.sort(Comparator.comparing(item -> String.valueOf(item.get("id")), String.CASE_INSENSITIVE_ORDER));
|
||||
return installed;
|
||||
}
|
||||
|
||||
public synchronized List<Map<String, Object>> availableArtifacts() {
|
||||
List<String> artifacts = scanArtifactFileNames();
|
||||
Set<String> loadedArtifacts = new HashSet<>();
|
||||
for (String source : extensionSourceById.values()) {
|
||||
if (source != null && source.toLowerCase(Locale.ROOT).endsWith(".jar")) {
|
||||
loadedArtifacts.add(source);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> available = new ArrayList<>();
|
||||
for (String artifact : artifacts) {
|
||||
available.add(Map.of(
|
||||
"fileName", artifact,
|
||||
"loaded", loadedArtifacts.contains(artifact)
|
||||
));
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
public void enableAll() {
|
||||
for (MinePanelExtension extension : loadedExtensions) {
|
||||
try {
|
||||
extension.onEnable();
|
||||
plugin.getLogger().info("Enabled extension " + extension.id() + " (" + extension.displayName() + ")");
|
||||
} catch (Exception exception) {
|
||||
context.commandRegistry().unregisterForExtension(extension.id());
|
||||
plugin.getLogger().warning("Could not enable extension " + extension.id() + ": " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void registerWebRoutes(ExtensionWebRegistry webRegistry) {
|
||||
this.activeWebRegistry = webRegistry;
|
||||
for (MinePanelExtension extension : loadedExtensions) {
|
||||
try {
|
||||
extension.registerWebRoutes(webRegistry);
|
||||
} catch (Exception exception) {
|
||||
plugin.getLogger().warning("Could not register web routes for extension " + extension.id() + ": " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<ExtensionNavigationTab> navigationTabs() {
|
||||
List<ExtensionNavigationTab> tabs = new ArrayList<>();
|
||||
for (MinePanelExtension extension : loadedExtensions) {
|
||||
try {
|
||||
tabs.addAll(extension.navigationTabs());
|
||||
} catch (Exception exception) {
|
||||
plugin.getLogger().warning("Could not read tabs for extension " + extension.id() + ": " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
public synchronized boolean supportsSettings(String extensionId) {
|
||||
String normalizedId = normalizeExtensionId(extensionId);
|
||||
if (normalizedId.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (MinePanelExtension extension : loadedExtensions) {
|
||||
if (!normalizeExtensionId(extension.id()).equals(normalizedId)) {
|
||||
continue;
|
||||
}
|
||||
return extension instanceof ExtensionConfigurable;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public synchronized boolean applySettings(String extensionId, String settingsJson) {
|
||||
String normalizedId = normalizeExtensionId(extensionId);
|
||||
if (normalizedId.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (MinePanelExtension extension : loadedExtensions) {
|
||||
if (!normalizeExtensionId(extension.id()).equals(normalizedId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(extension instanceof ExtensionConfigurable configurable)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
configurable.onSettingsUpdated(settingsJson == null ? "{}" : settingsJson);
|
||||
return true;
|
||||
} catch (Exception exception) {
|
||||
plugin.getLogger().warning("Could not apply live settings for extension " + extension.id() + ": " + exception.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public synchronized void disableAll() {
|
||||
ListIterator<MinePanelExtension> iterator = loadedExtensions.listIterator(loadedExtensions.size());
|
||||
while (iterator.hasPrevious()) {
|
||||
MinePanelExtension extension = iterator.previous();
|
||||
try {
|
||||
extension.onDisable();
|
||||
} catch (Exception exception) {
|
||||
plugin.getLogger().warning("Could not disable extension " + extension.id() + ": " + exception.getMessage());
|
||||
} finally {
|
||||
context.commandRegistry().unregisterForExtension(extension.id());
|
||||
}
|
||||
}
|
||||
|
||||
context.commandRegistry().unregisterAll();
|
||||
|
||||
for (URLClassLoader classLoader : classLoaders) {
|
||||
try {
|
||||
classLoader.close();
|
||||
} catch (IOException ignored) {
|
||||
// Best effort cleanup.
|
||||
}
|
||||
}
|
||||
classLoaders.clear();
|
||||
loadedArtifacts.clear();
|
||||
loadedIds.clear();
|
||||
extensionSourceById.clear();
|
||||
loadedExtensions.clear();
|
||||
}
|
||||
|
||||
private void loadJarExtensions(Path jarFile, List<MinePanelExtension> newlyLoaded) {
|
||||
URLClassLoader classLoader = null;
|
||||
try {
|
||||
URL url = jarFile.toUri().toURL();
|
||||
classLoader = new URLClassLoader(new URL[]{url}, plugin.getClass().getClassLoader());
|
||||
|
||||
ServiceLoader<MinePanelExtension> serviceLoader = ServiceLoader.load(MinePanelExtension.class, classLoader);
|
||||
int found = 0;
|
||||
int registered = 0;
|
||||
for (MinePanelExtension extension : serviceLoader) {
|
||||
found++;
|
||||
if (registerLoadedExtension(extension, jarFile.getFileName().toString())) {
|
||||
registered++;
|
||||
if (newlyLoaded != null) {
|
||||
newlyLoaded.add(extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (found == 0) {
|
||||
plugin.getLogger().warning("No MinePanel extension entry found in " + jarFile.getFileName() + ". Add META-INF/services/" + MinePanelExtension.class.getName());
|
||||
}
|
||||
|
||||
if (registered > 0) {
|
||||
classLoaders.add(classLoader);
|
||||
loadedArtifacts.add(jarFile.getFileName().toString());
|
||||
classLoader = null;
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
plugin.getLogger().warning("Could not load extension jar " + jarFile.getFileName() + ": " + exception.getMessage());
|
||||
} finally {
|
||||
if (classLoader != null) {
|
||||
try {
|
||||
classLoader.close();
|
||||
} catch (IOException ignored) {
|
||||
// Best effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean registerLoadedExtension(MinePanelExtension extension, String source) {
|
||||
if (extension == null || extension.id() == null || extension.id().isBlank()) {
|
||||
plugin.getLogger().warning("Skipping extension with invalid id from " + source);
|
||||
return false;
|
||||
}
|
||||
|
||||
String id = extension.id().trim().toLowerCase(Locale.ROOT);
|
||||
if (!loadedIds.add(id)) {
|
||||
plugin.getLogger().warning("Skipping duplicate extension id: " + extension.id());
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
extension.onLoad(context);
|
||||
loadedExtensions.add(extension);
|
||||
extensionSourceById.put(id, source);
|
||||
plugin.getLogger().info("Loaded extension " + extension.id() + " from " + source);
|
||||
return true;
|
||||
} catch (Exception exception) {
|
||||
loadedIds.remove(id);
|
||||
plugin.getLogger().warning("Could not initialize extension " + extension.id() + ": " + exception.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public record ReloadResult(int scannedArtifactCount, List<String> loadedExtensionIds, List<String> warnings) {
|
||||
}
|
||||
|
||||
private List<String> scanArtifactFileNames() {
|
||||
if (extensionsDirectory == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
try (Stream<Path> files = Files.list(extensionsDirectory)) {
|
||||
return files
|
||||
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
|
||||
.map(path -> path.getFileName().toString())
|
||||
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||
.toList();
|
||||
} catch (IOException exception) {
|
||||
plugin.getLogger().warning("Could not read extension artifacts: " + exception.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private int compareJarsNewestFirst(Path left, Path right) {
|
||||
long leftModified = lastModifiedMillis(left);
|
||||
long rightModified = lastModifiedMillis(right);
|
||||
int byModified = Long.compare(rightModified, leftModified);
|
||||
if (byModified != 0) {
|
||||
return byModified;
|
||||
}
|
||||
|
||||
String leftName = left.getFileName().toString().toLowerCase(Locale.ROOT);
|
||||
String rightName = right.getFileName().toString().toLowerCase(Locale.ROOT);
|
||||
return rightName.compareTo(leftName);
|
||||
}
|
||||
|
||||
private long lastModifiedMillis(Path file) {
|
||||
try {
|
||||
return Files.getLastModifiedTime(file).toMillis();
|
||||
} catch (IOException ignored) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupDuplicateArtifactsOnStartup(Path extensionDirectory) {
|
||||
List<Path> jars;
|
||||
try (Stream<Path> files = Files.list(extensionDirectory)) {
|
||||
jars = files
|
||||
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
|
||||
.toList();
|
||||
} catch (IOException exception) {
|
||||
plugin.getLogger().warning("Could not inspect extension artifacts for startup cleanup: " + exception.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (jars.size() < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Path> keepByKey = new HashMap<>();
|
||||
Map<String, ArtifactVersionToken> keepVersionByKey = new HashMap<>();
|
||||
|
||||
for (Path jar : jars) {
|
||||
String fileName = jar.getFileName().toString();
|
||||
String key = extensionKeyFromArtifact(fileName);
|
||||
if (key.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ArtifactVersionToken currentVersion = parseArtifactVersion(fileName);
|
||||
Path keptPath = keepByKey.get(key);
|
||||
ArtifactVersionToken keptVersion = keepVersionByKey.get(key);
|
||||
|
||||
if (keptPath == null || keptVersion == null) {
|
||||
keepByKey.put(key, jar);
|
||||
keepVersionByKey.put(key, currentVersion);
|
||||
continue;
|
||||
}
|
||||
|
||||
int compare = compareArtifactVersions(currentVersion, keptVersion);
|
||||
if (compare > 0 || (compare == 0 && compareJarsNewestFirst(jar, keptPath) < 0)) {
|
||||
keepByKey.put(key, jar);
|
||||
keepVersionByKey.put(key, currentVersion);
|
||||
}
|
||||
}
|
||||
|
||||
for (Path jar : jars) {
|
||||
String fileName = jar.getFileName().toString();
|
||||
String key = extensionKeyFromArtifact(fileName);
|
||||
if (key.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Path kept = keepByKey.get(key);
|
||||
if (kept == null || kept.equals(jar)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Files.deleteIfExists(jar);
|
||||
plugin.getLogger().info("Removed old extension artifact on startup: " + fileName + " (kept " + kept.getFileName() + ")");
|
||||
} catch (IOException exception) {
|
||||
plugin.getLogger().warning("Could not delete old extension artifact " + fileName + " on startup: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String extensionKeyFromArtifact(String fileName) {
|
||||
if (fileName == null || fileName.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String normalized = fileName.trim().toLowerCase(Locale.ROOT);
|
||||
if (!normalized.endsWith(".jar") || !normalized.startsWith("minepanel-extension-")) {
|
||||
return "";
|
||||
}
|
||||
|
||||
normalized = normalized.substring(0, normalized.length() - 4);
|
||||
normalized = normalized.substring("minepanel-extension-".length());
|
||||
|
||||
int alphaSplit = normalized.lastIndexOf("-alpha-");
|
||||
if (alphaSplit > 0) {
|
||||
normalized = normalized.substring(0, alphaSplit);
|
||||
} else {
|
||||
int semverSplit = normalized.lastIndexOf('-');
|
||||
if (semverSplit > 0) {
|
||||
String suffix = normalized.substring(semverSplit + 1);
|
||||
if (isNumericVersionSuffix(suffix)) {
|
||||
normalized = normalized.substring(0, semverSplit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.replaceAll("[^a-z0-9]", "");
|
||||
}
|
||||
|
||||
private String normalizeExtensionId(String rawId) {
|
||||
if (rawId == null || rawId.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return rawId.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
|
||||
}
|
||||
|
||||
private ArtifactVersionToken parseArtifactVersion(String fileName) {
|
||||
if (fileName == null || fileName.isBlank()) {
|
||||
return ArtifactVersionToken.unknown();
|
||||
}
|
||||
|
||||
String normalized = fileName.trim().toLowerCase(Locale.ROOT);
|
||||
if (normalized.endsWith(".jar")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 4);
|
||||
}
|
||||
|
||||
int alphaSplit = normalized.lastIndexOf("-alpha-");
|
||||
if (alphaSplit > 0) {
|
||||
String buildRaw = normalized.substring(alphaSplit + "-alpha-".length());
|
||||
try {
|
||||
return ArtifactVersionToken.alpha(Integer.parseInt(buildRaw));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return ArtifactVersionToken.unknown();
|
||||
}
|
||||
}
|
||||
|
||||
int semverSplit = normalized.lastIndexOf('-');
|
||||
if (semverSplit > 0) {
|
||||
String suffix = normalized.substring(semverSplit + 1);
|
||||
if (isNumericVersionSuffix(suffix)) {
|
||||
List<Integer> parts = new ArrayList<>();
|
||||
for (String part : suffix.split("\\.")) {
|
||||
if (part.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
parts.add(Integer.parseInt(part));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return ArtifactVersionToken.unknown();
|
||||
}
|
||||
}
|
||||
if (!parts.isEmpty()) {
|
||||
return ArtifactVersionToken.semver(parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ArtifactVersionToken.unknown();
|
||||
}
|
||||
|
||||
private boolean isNumericVersionSuffix(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (!Character.isDigit(c) && c != '.') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private int compareArtifactVersions(ArtifactVersionToken left, ArtifactVersionToken right) {
|
||||
if (left.kind() != right.kind()) {
|
||||
return Integer.compare(left.kind().priority, right.kind().priority);
|
||||
}
|
||||
|
||||
if (left.kind() == ArtifactVersionKind.ALPHA) {
|
||||
return Integer.compare(left.alphaBuild(), right.alphaBuild());
|
||||
}
|
||||
|
||||
if (left.kind() == ArtifactVersionKind.SEMVER) {
|
||||
int max = Math.max(left.semverParts().size(), right.semverParts().size());
|
||||
for (int i = 0; i < max; i++) {
|
||||
int l = i < left.semverParts().size() ? left.semverParts().get(i) : 0;
|
||||
int r = i < right.semverParts().size() ? right.semverParts().get(i) : 0;
|
||||
if (l != r) {
|
||||
return Integer.compare(l, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private enum ArtifactVersionKind {
|
||||
UNKNOWN(0),
|
||||
ALPHA(1),
|
||||
SEMVER(2);
|
||||
|
||||
private final int priority;
|
||||
|
||||
ArtifactVersionKind(int priority) {
|
||||
this.priority = priority;
|
||||
}
|
||||
}
|
||||
|
||||
private record ArtifactVersionToken(ArtifactVersionKind kind, int alphaBuild, List<Integer> semverParts) {
|
||||
private static ArtifactVersionToken unknown() {
|
||||
return new ArtifactVersionToken(ArtifactVersionKind.UNKNOWN, -1, List.of());
|
||||
}
|
||||
|
||||
private static ArtifactVersionToken alpha(int build) {
|
||||
return new ArtifactVersionToken(ArtifactVersionKind.ALPHA, build, List.of());
|
||||
}
|
||||
|
||||
private static ArtifactVersionToken semver(List<Integer> parts) {
|
||||
return new ArtifactVersionToken(ArtifactVersionKind.SEMVER, -1, parts == null ? List.of() : List.copyOf(parts));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.winniepat.minePanel.extensions;
|
||||
|
||||
public record ExtensionNavigationTab(
|
||||
String category,
|
||||
String label,
|
||||
String path
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.winniepat.minePanel.extensions;
|
||||
|
||||
import de.winniepat.minePanel.users.PanelUser;
|
||||
import spark.Request;
|
||||
import spark.Response;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ExtensionRouteHandler {
|
||||
Object handle(Request request, Response response, PanelUser user) throws Exception;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.winniepat.minePanel.extensions;
|
||||
|
||||
import de.winniepat.minePanel.users.PanelPermission;
|
||||
import spark.Response;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface ExtensionWebRegistry {
|
||||
|
||||
void get(String path, PanelPermission permission, ExtensionRouteHandler handler);
|
||||
|
||||
void post(String path, PanelPermission permission, ExtensionRouteHandler handler);
|
||||
|
||||
String json(Response response, int status, Map<String, Object> payload);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.winniepat.minePanel.extensions;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Service Provider Interface (SPI) for MinePanel extensions.
|
||||
* <p>
|
||||
* Implementations are discovered through Java {@code ServiceLoader} and loaded by the
|
||||
* extension manager during MinePanel startup.
|
||||
* </p>
|
||||
* <p>
|
||||
* Typical lifecycle:
|
||||
* </p>
|
||||
* <ol>
|
||||
* <li>{@link #onLoad(ExtensionContext)}</li>
|
||||
* <li>{@link #onEnable()}</li>
|
||||
* <li>{@link #registerWebRoutes(ExtensionWebRegistry)}</li>
|
||||
* <li>{@link #navigationTabs()}</li>
|
||||
* <li>{@link #onDisable()} on shutdown</li>
|
||||
* </ol>
|
||||
*/
|
||||
public interface MinePanelExtension {
|
||||
|
||||
/**
|
||||
* Returns the stable, unique id of this extension.
|
||||
* <p>
|
||||
* The id is used for runtime bookkeeping and should remain unchanged across versions.
|
||||
* </p>
|
||||
*
|
||||
* @return extension id (for example {@code "reports"})
|
||||
*/
|
||||
String id();
|
||||
|
||||
/**
|
||||
* Returns the human-readable display name shown in UI/status views.
|
||||
*
|
||||
* @return extension display name
|
||||
*/
|
||||
String displayName();
|
||||
|
||||
/**
|
||||
* Called once when the extension is loaded and before it is enabled.
|
||||
* <p>
|
||||
* Use this for initialization that requires MinePanel services, such as setting up
|
||||
* database schema or caching dependencies from the provided context.
|
||||
* </p>
|
||||
*
|
||||
* @param context runtime context provided by MinePanel
|
||||
*/
|
||||
default void onLoad(ExtensionContext context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after {@link #onLoad(ExtensionContext)} when the extension should become active.
|
||||
* <p>
|
||||
* Use this for registering listeners, commands, or background tasks.
|
||||
* </p>
|
||||
*/
|
||||
default void onEnable() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when MinePanel is disabling the extension.
|
||||
* <p>
|
||||
* Use this hook to release resources and stop tasks started in {@link #onEnable()}.
|
||||
* </p>
|
||||
*/
|
||||
default void onDisable() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows the extension to register HTTP routes in the MinePanel web server.
|
||||
*
|
||||
* @param webRegistry route registration facade
|
||||
*/
|
||||
default void registerWebRoutes(ExtensionWebRegistry webRegistry) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sidebar navigation tabs contributed by this extension.
|
||||
*
|
||||
* @return list of extension tabs, or an empty list if none are contributed
|
||||
*/
|
||||
default List<ExtensionNavigationTab> navigationTabs() {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package de.winniepat.minePanel.extensions.airstrike;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import de.winniepat.minePanel.extensions.ExtensionContext;
|
||||
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
|
||||
import de.winniepat.minePanel.extensions.MinePanelExtension;
|
||||
import de.winniepat.minePanel.persistence.KnownPlayer;
|
||||
import de.winniepat.minePanel.users.PanelPermission;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.TNTPrimed;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class AirstrikeExtension implements MinePanelExtension {
|
||||
|
||||
private static final int DEFAULT_TNT = 5000;
|
||||
private static final int MAX_TNT = 20000;
|
||||
private static final int WAVE_COUNT = 20;
|
||||
private static final long WAVE_INTERVAL_TICKS = 12L;
|
||||
private static final double DROP_HEIGHT = 52.0;
|
||||
private static final double HORIZONTAL_SPREAD = 5.0;
|
||||
|
||||
private final Gson gson = new Gson();
|
||||
private ExtensionContext context;
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "airstrike";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "Airstrike";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad(ExtensionContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
|
||||
webRegistry.post("/api/extensions/airstrike/launch", PanelPermission.MANAGE_AIRSTRIKE, (request, response, user) -> {
|
||||
AirstrikePayload payload = gson.fromJson(request.body(), AirstrikePayload.class);
|
||||
if (payload == null || (isBlank(payload.uuid()) && isBlank(payload.username()))) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
|
||||
}
|
||||
|
||||
int amount = sanitizeAmount(payload.amount());
|
||||
|
||||
TargetSnapshot target;
|
||||
try {
|
||||
target = context.schedulerBridge().callGlobal(
|
||||
() -> resolveOnlineTarget(payload.uuid(), payload.username()),
|
||||
2,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
return webRegistry.json(response, 500, Map.of("error", "interrupted"));
|
||||
} catch (ExecutionException | TimeoutException exception) {
|
||||
return webRegistry.json(response, 500, Map.of("error", "lookup_failed"));
|
||||
}
|
||||
|
||||
if (target == null) {
|
||||
return webRegistry.json(response, 404, Map.of("error", "player_not_online"));
|
||||
}
|
||||
|
||||
try {
|
||||
launchAirstrike(target.uuid(), target.username(), user.username(), amount);
|
||||
} catch (IllegalStateException exception) {
|
||||
return webRegistry.json(response, 500, Map.of("error", "airstrike_schedule_failed"));
|
||||
}
|
||||
|
||||
return webRegistry.json(response, 200, Map.of(
|
||||
"ok", true,
|
||||
"targetUuid", target.uuid().toString(),
|
||||
"targetUsername", target.username(),
|
||||
"tntCount", amount
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
private void launchAirstrike(UUID targetUuid, String targetName, String actorName, int totalTnt) {
|
||||
context.panelLogger().log("AUDIT", actorName, "Triggered airstrike on " + targetName + " with " + totalTnt + " TNT");
|
||||
|
||||
final de.winniepat.minePanel.util.ServerSchedulerBridge.CancellableTask[] repeatingTask = new de.winniepat.minePanel.util.ServerSchedulerBridge.CancellableTask[1];
|
||||
|
||||
Runnable waveTask = new Runnable() {
|
||||
private int remainingTnt = totalTnt;
|
||||
private int remainingWaves = WAVE_COUNT;
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Player onlineTarget = context.plugin().getServer().getPlayer(targetUuid);
|
||||
if (onlineTarget == null || !onlineTarget.isOnline()) {
|
||||
context.panelLogger().log("SYSTEM", "airstrike", "Airstrike cancelled because target went offline: " + targetName);
|
||||
if (repeatingTask[0] != null) {
|
||||
repeatingTask[0].cancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
int tntThisWave = (int) Math.ceil((double) remainingTnt / Math.max(1, remainingWaves));
|
||||
boolean dispatched = runOnPlayerScheduler(onlineTarget, () -> {
|
||||
for (int index = 0; index < tntThisWave; index++) {
|
||||
spawnStrikeTnt(onlineTarget);
|
||||
}
|
||||
});
|
||||
|
||||
if (!dispatched) {
|
||||
context.panelLogger().log("SYSTEM", "airstrike", "Airstrike cancelled because player scheduler dispatch failed: " + targetName);
|
||||
if (repeatingTask[0] != null) {
|
||||
repeatingTask[0].cancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
remainingTnt = Math.max(0, remainingTnt - tntThisWave);
|
||||
|
||||
remainingWaves--;
|
||||
|
||||
if (remainingWaves <= 0 || remainingTnt <= 0) {
|
||||
if (repeatingTask[0] != null) {
|
||||
repeatingTask[0].cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
repeatingTask[0] = context.schedulerBridge().runRepeatingGlobal(waveTask, 0L, WAVE_INTERVAL_TICKS);
|
||||
}
|
||||
|
||||
private boolean runOnPlayerScheduler(Player player, Runnable task) {
|
||||
if (!context.schedulerBridge().isFolia()) {
|
||||
task.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
Object entityScheduler = player.getClass().getMethod("getScheduler").invoke(player);
|
||||
Method runMethod = entityScheduler.getClass().getMethod("run", org.bukkit.plugin.Plugin.class, java.util.function.Consumer.class, Runnable.class);
|
||||
runMethod.invoke(entityScheduler, context.plugin(), (java.util.function.Consumer<Object>) ignored -> task.run(), null);
|
||||
return true;
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void spawnStrikeTnt(Player target) {
|
||||
ThreadLocalRandom random = ThreadLocalRandom.current();
|
||||
Location targetLocation = target.getLocation();
|
||||
Vector velocity = target.getVelocity();
|
||||
// Lead the strike slightly so waves keep up with sprinting players.
|
||||
double leadFactor = 6.0;
|
||||
double predictedX = targetLocation.getX() + (velocity.getX() * leadFactor);
|
||||
double predictedZ = targetLocation.getZ() + (velocity.getZ() * leadFactor);
|
||||
|
||||
double angle = random.nextDouble(0.0, Math.PI * 2.0);
|
||||
double radius = random.nextDouble(0.0, HORIZONTAL_SPREAD);
|
||||
double offsetX = Math.cos(angle) * radius;
|
||||
double offsetZ = Math.sin(angle) * radius;
|
||||
|
||||
Location strikeCenter = new Location(targetLocation.getWorld(), predictedX, targetLocation.getY(), predictedZ);
|
||||
Location spawnLocation = strikeCenter.add(offsetX, DROP_HEIGHT + random.nextDouble(0.0, 8.0), offsetZ);
|
||||
TNTPrimed tnt = targetLocation.getWorld().spawn(spawnLocation, TNTPrimed.class);
|
||||
tnt.setFuseTicks(65 + random.nextInt(20));
|
||||
|
||||
Vector tntVelocity = new Vector(
|
||||
random.nextDouble(-0.08, 0.08),
|
||||
-1.85 - random.nextDouble(0.0, 0.45),
|
||||
random.nextDouble(-0.08, 0.08)
|
||||
);
|
||||
tnt.setVelocity(tntVelocity);
|
||||
}
|
||||
|
||||
private TargetSnapshot resolveOnlineTarget(String rawUuid, String rawUsername) {
|
||||
if (!isBlank(rawUuid)) {
|
||||
try {
|
||||
Player online = context.plugin().getServer().getPlayer(UUID.fromString(rawUuid.trim()));
|
||||
if (online != null) {
|
||||
return new TargetSnapshot(online.getUniqueId(), online.getName());
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Try username fallback.
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBlank(rawUsername)) {
|
||||
Player online = context.plugin().getServer().getPlayerExact(rawUsername.trim());
|
||||
if (online != null) {
|
||||
return new TargetSnapshot(online.getUniqueId(), online.getName());
|
||||
}
|
||||
|
||||
Optional<KnownPlayer> known = context.knownPlayerRepository().findByUsername(rawUsername.trim());
|
||||
if (known.isPresent()) {
|
||||
Player knownOnline = context.plugin().getServer().getPlayer(known.get().uuid());
|
||||
if (knownOnline != null) {
|
||||
return new TargetSnapshot(knownOnline.getUniqueId(), knownOnline.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private int sanitizeAmount(Integer requestedAmount) {
|
||||
if (requestedAmount == null) {
|
||||
return DEFAULT_TNT;
|
||||
}
|
||||
return Math.max(1, Math.min(MAX_TNT, requestedAmount));
|
||||
}
|
||||
|
||||
private record AirstrikePayload(String uuid, String username, Integer amount) {
|
||||
}
|
||||
|
||||
private record TargetSnapshot(UUID uuid, String username) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.winniepat.minePanel.extensions.announcements;
|
||||
|
||||
public record Announcement(
|
||||
long id,
|
||||
String message,
|
||||
boolean enabled,
|
||||
int sortOrder,
|
||||
long createdAt,
|
||||
long updatedAt
|
||||
) {
|
||||
}
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package de.winniepat.minePanel.extensions.announcements;
|
||||
|
||||
import de.winniepat.minePanel.persistence.Database;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class AnnouncementRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public AnnouncementRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public void initializeSchema() {
|
||||
try (Connection connection = database.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS ext_announcements ("
|
||||
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
+ "message TEXT NOT NULL,"
|
||||
+ "enabled INTEGER NOT NULL DEFAULT 1,"
|
||||
+ "sort_order INTEGER NOT NULL DEFAULT 0,"
|
||||
+ "created_at INTEGER NOT NULL,"
|
||||
+ "updated_at INTEGER NOT NULL"
|
||||
+ ")");
|
||||
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS ext_announcements_config ("
|
||||
+ "id INTEGER PRIMARY KEY,"
|
||||
+ "enabled INTEGER NOT NULL DEFAULT 0,"
|
||||
+ "interval_seconds INTEGER NOT NULL DEFAULT 300,"
|
||||
+ "updated_at INTEGER NOT NULL DEFAULT 0"
|
||||
+ ")");
|
||||
|
||||
statement.execute("INSERT OR IGNORE INTO ext_announcements_config(id, enabled, interval_seconds, updated_at) VALUES (1, 0, 300, 0)");
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not initialize announcements schema", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public AnnouncementConfig readConfig() {
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("SELECT enabled, interval_seconds, updated_at FROM ext_announcements_config WHERE id = 1");
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next()) {
|
||||
return new AnnouncementConfig(false, 300, 0L);
|
||||
}
|
||||
|
||||
return new AnnouncementConfig(
|
||||
resultSet.getInt("enabled") == 1,
|
||||
Math.max(10, resultSet.getInt("interval_seconds")),
|
||||
resultSet.getLong("updated_at")
|
||||
);
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not read announcements config", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void saveConfig(boolean enabled, int intervalSeconds, long updatedAt) {
|
||||
int normalizedInterval = Math.max(10, Math.min(86_400, intervalSeconds));
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"UPDATE ext_announcements_config SET enabled = ?, interval_seconds = ?, updated_at = ? WHERE id = 1"
|
||||
)) {
|
||||
statement.setInt(1, enabled ? 1 : 0);
|
||||
statement.setInt(2, normalizedInterval);
|
||||
statement.setLong(3, Math.max(0L, updatedAt));
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not save announcements config", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public List<Announcement> listMessages() {
|
||||
List<Announcement> messages = new ArrayList<>();
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT id, message, enabled, sort_order, created_at, updated_at FROM ext_announcements ORDER BY sort_order ASC, id ASC"
|
||||
);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
messages.add(new Announcement(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getString("message"),
|
||||
resultSet.getInt("enabled") == 1,
|
||||
resultSet.getInt("sort_order"),
|
||||
resultSet.getLong("created_at"),
|
||||
resultSet.getLong("updated_at")
|
||||
));
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not list announcements", exception);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
public long createMessage(String message, long now) {
|
||||
int nextSortOrder = nextSortOrder();
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"INSERT INTO ext_announcements(message, enabled, sort_order, created_at, updated_at) VALUES (?, 1, ?, ?, ?)",
|
||||
Statement.RETURN_GENERATED_KEYS
|
||||
)) {
|
||||
statement.setString(1, message);
|
||||
statement.setInt(2, nextSortOrder);
|
||||
statement.setLong(3, now);
|
||||
statement.setLong(4, now);
|
||||
statement.executeUpdate();
|
||||
|
||||
try (ResultSet resultSet = statement.getGeneratedKeys()) {
|
||||
if (resultSet.next()) {
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
return -1L;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not create announcement", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean deleteMessage(long id) {
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("DELETE FROM ext_announcements WHERE id = ?")) {
|
||||
statement.setLong(1, id);
|
||||
return statement.executeUpdate() > 0;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not delete announcement", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean setMessageEnabled(long id, boolean enabled, long updatedAt) {
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("UPDATE ext_announcements SET enabled = ?, updated_at = ? WHERE id = ?")) {
|
||||
statement.setInt(1, enabled ? 1 : 0);
|
||||
statement.setLong(2, updatedAt);
|
||||
statement.setLong(3, id);
|
||||
return statement.executeUpdate() > 0;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not update announcement", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private int nextSortOrder() {
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("SELECT COALESCE(MAX(sort_order), 0) + 1 AS next_order FROM ext_announcements");
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return resultSet.getInt("next_order");
|
||||
}
|
||||
return 1;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not compute announcement sort order", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public record AnnouncementConfig(boolean enabled, int intervalSeconds, long updatedAt) {
|
||||
}
|
||||
}
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package de.winniepat.minePanel.extensions.announcements;
|
||||
|
||||
import de.winniepat.minePanel.extensions.ExtensionContext;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class AnnouncementService {
|
||||
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
private static final DateTimeFormatter DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final ExtensionContext context;
|
||||
private final AnnouncementRepository repository;
|
||||
|
||||
private volatile boolean enabled;
|
||||
private volatile int intervalSeconds;
|
||||
private volatile long nextRunAt;
|
||||
private int rotationCursor;
|
||||
private de.winniepat.minePanel.util.ServerSchedulerBridge.CancellableTask task;
|
||||
|
||||
public AnnouncementService(ExtensionContext context, AnnouncementRepository repository) {
|
||||
this.context = context;
|
||||
this.repository = repository;
|
||||
|
||||
AnnouncementRepository.AnnouncementConfig config = repository.readConfig();
|
||||
this.enabled = config.enabled();
|
||||
this.intervalSeconds = Math.max(10, config.intervalSeconds());
|
||||
this.nextRunAt = System.currentTimeMillis() + (long) this.intervalSeconds * 1000L;
|
||||
this.rotationCursor = 0;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
stop();
|
||||
this.task = context.schedulerBridge().runRepeatingGlobal(this::tick, 20L, 20L);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (task != null) {
|
||||
task.cancel();
|
||||
task = null;
|
||||
}
|
||||
}
|
||||
|
||||
public AnnouncementState state() {
|
||||
return new AnnouncementState(enabled, intervalSeconds, nextRunAt, repository.listMessages());
|
||||
}
|
||||
|
||||
public void updateConfig(boolean enabled, int intervalSeconds) {
|
||||
this.enabled = enabled;
|
||||
this.intervalSeconds = Math.max(10, Math.min(86_400, intervalSeconds));
|
||||
this.nextRunAt = System.currentTimeMillis() + (long) this.intervalSeconds * 1000L;
|
||||
repository.saveConfig(this.enabled, this.intervalSeconds, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public boolean sendNow() {
|
||||
Announcement next = nextAnnouncement();
|
||||
if (next == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
broadcast(next.message());
|
||||
nextRunAt = System.currentTimeMillis() + (long) intervalSeconds * 1000L;
|
||||
return true;
|
||||
}
|
||||
|
||||
public long addMessage(String message) {
|
||||
long id = repository.createMessage(message, System.currentTimeMillis());
|
||||
if (id > 0) {
|
||||
nextRunAt = System.currentTimeMillis() + (long) intervalSeconds * 1000L;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
public boolean deleteMessage(long id) {
|
||||
boolean deleted = repository.deleteMessage(id);
|
||||
if (deleted) {
|
||||
rotationCursor = 0;
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public boolean setMessageEnabled(long id, boolean enabled) {
|
||||
boolean updated = repository.setMessageEnabled(id, enabled, System.currentTimeMillis());
|
||||
if (updated) {
|
||||
rotationCursor = 0;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private void tick() {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (now < nextRunAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
Announcement next = nextAnnouncement();
|
||||
if (next == null) {
|
||||
nextRunAt = now + (long) intervalSeconds * 1000L;
|
||||
return;
|
||||
}
|
||||
|
||||
broadcast(next.message());
|
||||
nextRunAt = now + (long) intervalSeconds * 1000L;
|
||||
}
|
||||
|
||||
private Announcement nextAnnouncement() {
|
||||
List<Announcement> enabledMessages = repository.listMessages().stream()
|
||||
.filter(Announcement::enabled)
|
||||
.toList();
|
||||
|
||||
if (enabledMessages.isEmpty()) {
|
||||
rotationCursor = 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rotationCursor >= enabledMessages.size()) {
|
||||
rotationCursor = 0;
|
||||
}
|
||||
|
||||
Announcement selected = enabledMessages.get(rotationCursor);
|
||||
rotationCursor = (rotationCursor + 1) % enabledMessages.size();
|
||||
return selected;
|
||||
}
|
||||
|
||||
private void broadcast(String message) {
|
||||
long nowMillis = System.currentTimeMillis();
|
||||
List<Player> onlinePlayers = List.copyOf(context.plugin().getServer().getOnlinePlayers());
|
||||
|
||||
for (Player onlinePlayer : onlinePlayers) {
|
||||
String resolved = resolvePlaceholders(message, onlinePlayer, nowMillis, onlinePlayers.size());
|
||||
onlinePlayer.sendMessage(resolved);
|
||||
}
|
||||
|
||||
context.panelLogger().log("SYSTEM", "ANNOUNCEMENTS", "Broadcasted announcement: " + message);
|
||||
}
|
||||
|
||||
private String resolvePlaceholders(String template, Player player, long nowMillis, int onlineCount) {
|
||||
if (template == null || template.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
ZoneId zoneId = ZoneId.systemDefault();
|
||||
var now = Instant.ofEpochMilli(nowMillis).atZone(zoneId);
|
||||
double tps = readPrimaryTps();
|
||||
|
||||
String resolved = template;
|
||||
resolved = resolved.replace("%player%", player == null ? "Player" : player.getName());
|
||||
resolved = resolved.replace("%time%", TIME_FORMAT.format(now));
|
||||
resolved = resolved.replace("%date%", DATE_FORMAT.format(now));
|
||||
resolved = resolved.replace("%datetime%", DATETIME_FORMAT.format(now));
|
||||
resolved = resolved.replace("%online%", String.valueOf(onlineCount));
|
||||
resolved = resolved.replace("%max_players%", String.valueOf(context.plugin().getServer().getMaxPlayers()));
|
||||
resolved = resolved.replace("%world%", player == null || player.getWorld() == null ? "unknown" : player.getWorld().getName());
|
||||
resolved = resolved.replace("%server%", context.plugin().getServer().getName());
|
||||
resolved = resolved.replace("%tps%", tps < 0 ? "N/A" : String.format(Locale.US, "%.2f", tps));
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private double readPrimaryTps() {
|
||||
try {
|
||||
Object result = context.plugin().getServer().getClass().getMethod("getTPS").invoke(context.plugin().getServer());
|
||||
if (result instanceof double[] values && values.length > 0) {
|
||||
return values[0];
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Keep placeholder available on implementations without Paper TPS API.
|
||||
}
|
||||
return -1.0;
|
||||
}
|
||||
|
||||
public record AnnouncementState(
|
||||
boolean enabled,
|
||||
int intervalSeconds,
|
||||
long nextRunAt,
|
||||
List<Announcement> messages
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package de.winniepat.minePanel.extensions.announcements;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import de.winniepat.minePanel.extensions.ExtensionContext;
|
||||
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
|
||||
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
|
||||
import de.winniepat.minePanel.extensions.MinePanelExtension;
|
||||
import de.winniepat.minePanel.users.PanelPermission;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class AnnouncementsExtension implements MinePanelExtension {
|
||||
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
private ExtensionContext context;
|
||||
private AnnouncementService service;
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "announcements";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "Announcements";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad(ExtensionContext context) {
|
||||
this.context = context;
|
||||
AnnouncementRepository repository = new AnnouncementRepository(context.database());
|
||||
repository.initializeSchema();
|
||||
this.service = new AnnouncementService(context, repository);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
service.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (service != null) {
|
||||
service.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
|
||||
webRegistry.get("/api/extensions/announcements", PanelPermission.VIEW_ANNOUNCEMENTS, (request, response, user) -> {
|
||||
AnnouncementService.AnnouncementState state = service.state();
|
||||
List<Map<String, Object>> messages = state.messages().stream()
|
||||
.map(message -> Map.<String, Object>of(
|
||||
"id", message.id(),
|
||||
"message", message.message(),
|
||||
"enabled", message.enabled(),
|
||||
"sortOrder", message.sortOrder(),
|
||||
"createdAt", message.createdAt(),
|
||||
"updatedAt", message.updatedAt()
|
||||
))
|
||||
.toList();
|
||||
|
||||
return webRegistry.json(response, 200, Map.of(
|
||||
"enabled", state.enabled(),
|
||||
"intervalSeconds", state.intervalSeconds(),
|
||||
"nextRunAt", state.nextRunAt(),
|
||||
"messages", messages
|
||||
));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/announcements/config", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
|
||||
ConfigPayload payload = gson.fromJson(request.body(), ConfigPayload.class);
|
||||
if (payload == null || payload.enabled() == null || payload.intervalSeconds() == null) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
|
||||
}
|
||||
|
||||
service.updateConfig(payload.enabled(), payload.intervalSeconds());
|
||||
context.panelLogger().log("AUDIT", user.username(), "Updated announcements configuration");
|
||||
return webRegistry.json(response, 200, Map.of("ok", true));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/announcements/messages", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
|
||||
MessagePayload payload = gson.fromJson(request.body(), MessagePayload.class);
|
||||
String message = payload == null ? "" : sanitizeMessage(payload.message());
|
||||
if (message.isBlank()) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_message"));
|
||||
}
|
||||
|
||||
long id = service.addMessage(message);
|
||||
if (id <= 0) {
|
||||
return webRegistry.json(response, 500, Map.of("error", "create_failed"));
|
||||
}
|
||||
|
||||
context.panelLogger().log("AUDIT", user.username(), "Added announcement #" + id);
|
||||
return webRegistry.json(response, 201, Map.of("ok", true, "id", id));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/announcements/messages/:id/delete", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
|
||||
long id = parseId(request.params("id"));
|
||||
if (id <= 0) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_message_id"));
|
||||
}
|
||||
|
||||
if (!service.deleteMessage(id)) {
|
||||
return webRegistry.json(response, 404, Map.of("error", "message_not_found"));
|
||||
}
|
||||
|
||||
context.panelLogger().log("AUDIT", user.username(), "Deleted announcement #" + id);
|
||||
return webRegistry.json(response, 200, Map.of("ok", true));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/announcements/messages/:id/toggle", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
|
||||
long id = parseId(request.params("id"));
|
||||
TogglePayload payload = gson.fromJson(request.body(), TogglePayload.class);
|
||||
if (id <= 0 || payload == null || payload.enabled() == null) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
|
||||
}
|
||||
|
||||
if (!service.setMessageEnabled(id, payload.enabled())) {
|
||||
return webRegistry.json(response, 404, Map.of("error", "message_not_found"));
|
||||
}
|
||||
|
||||
context.panelLogger().log("AUDIT", user.username(), "Set announcement #" + id + " enabled=" + payload.enabled());
|
||||
return webRegistry.json(response, 200, Map.of("ok", true));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/announcements/send-now", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
|
||||
if (!service.sendNow()) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "no_enabled_messages"));
|
||||
}
|
||||
|
||||
context.panelLogger().log("AUDIT", user.username(), "Sent announcement manually");
|
||||
return webRegistry.json(response, 200, Map.of("ok", true));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ExtensionNavigationTab> navigationTabs() {
|
||||
return List.of(new ExtensionNavigationTab("server", "Announcements", "/dashboard/announcements"));
|
||||
}
|
||||
|
||||
private long parseId(String raw) {
|
||||
try {
|
||||
return Long.parseLong(raw);
|
||||
} catch (Exception ignored) {
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeMessage(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String trimmed = raw.trim();
|
||||
if (trimmed.length() > 220) {
|
||||
return trimmed.substring(0, 220);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private record ConfigPayload(Boolean enabled, Integer intervalSeconds) {
|
||||
}
|
||||
|
||||
private record MessagePayload(String message) {
|
||||
}
|
||||
|
||||
private record TogglePayload(Boolean enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package de.winniepat.minePanel.extensions.luckperms;
|
||||
|
||||
import de.winniepat.minePanel.extensions.*;
|
||||
import de.winniepat.minePanel.users.PanelPermission;
|
||||
import net.luckperms.api.LuckPerms;
|
||||
import net.luckperms.api.LuckPermsProvider;
|
||||
import net.luckperms.api.cacheddata.CachedMetaData;
|
||||
import net.luckperms.api.model.group.Group;
|
||||
import net.luckperms.api.model.user.User;
|
||||
import net.luckperms.api.query.QueryOptions;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class LuckPermsExtension implements MinePanelExtension {
|
||||
|
||||
private ExtensionContext context;
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "luckperms";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "LuckPerms Integration";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad(ExtensionContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
|
||||
webRegistry.get("/api/extensions/luckperms/player/:uuid", PanelPermission.VIEW_LUCKPERMS, (request, response, user) -> {
|
||||
UUID playerUuid;
|
||||
try {
|
||||
playerUuid = UUID.fromString(request.params("uuid"));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_uuid"));
|
||||
}
|
||||
|
||||
if (!isLuckPermsAvailable()) {
|
||||
return webRegistry.json(response, 200, Map.of(
|
||||
"available", false,
|
||||
"error", "luckperms_not_present"
|
||||
));
|
||||
}
|
||||
|
||||
try {
|
||||
LuckPerms api = LuckPermsProvider.get();
|
||||
User lpUser = api.getUserManager().loadUser(playerUuid).get(3, TimeUnit.SECONDS);
|
||||
QueryOptions queryOptions = api.getContextManager()
|
||||
.getQueryOptions(lpUser)
|
||||
.orElse(api.getContextManager().getStaticQueryOptions());
|
||||
|
||||
CachedMetaData metaData = lpUser.getCachedData().getMetaData(queryOptions);
|
||||
String prefix = sanitize(metaData.getPrefix());
|
||||
String suffix = sanitize(metaData.getSuffix());
|
||||
|
||||
List<String> groups = lpUser.getInheritedGroups(queryOptions).stream()
|
||||
.map(Group::getName)
|
||||
.distinct()
|
||||
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||
.toList();
|
||||
|
||||
List<String> permissions = lpUser.getCachedData().getPermissionData(queryOptions).getPermissionMap().entrySet().stream()
|
||||
.filter(entry -> Boolean.TRUE.equals(entry.getValue()))
|
||||
.map(Map.Entry::getKey)
|
||||
.distinct()
|
||||
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||
.toList();
|
||||
|
||||
List<String> limitedPermissions = permissions.stream().limit(200).toList();
|
||||
boolean permissionsTruncated = permissions.size() > limitedPermissions.size();
|
||||
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("available", true);
|
||||
payload.put("primaryGroup", sanitize(lpUser.getPrimaryGroup()));
|
||||
payload.put("prefix", prefix);
|
||||
payload.put("suffix", suffix);
|
||||
payload.put("groups", groups);
|
||||
payload.put("permissions", limitedPermissions);
|
||||
payload.put("permissionsCount", permissions.size());
|
||||
payload.put("permissionsTruncated", permissionsTruncated);
|
||||
payload.put("generatedAt", System.currentTimeMillis());
|
||||
return webRegistry.json(response, 200, payload);
|
||||
} catch (Exception exception) {
|
||||
return webRegistry.json(response, 500, Map.of(
|
||||
"available", false,
|
||||
"error", "luckperms_lookup_failed",
|
||||
"details", exception.getClass().getSimpleName()
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isLuckPermsAvailable() {
|
||||
return context.plugin().getServer().getPluginManager().getPlugin("LuckPerms") != null;
|
||||
}
|
||||
|
||||
private String sanitize(String value) {
|
||||
return value == null || value.isBlank() ? "-" : value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package de.winniepat.minePanel.extensions.maintenance;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import de.winniepat.minePanel.extensions.ExtensionContext;
|
||||
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
|
||||
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
|
||||
import de.winniepat.minePanel.extensions.MinePanelExtension;
|
||||
import de.winniepat.minePanel.users.PanelPermission;
|
||||
import org.bukkit.event.HandlerList;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class MaintenanceExtension implements MinePanelExtension {
|
||||
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
private ExtensionContext context;
|
||||
private MaintenanceService maintenanceService;
|
||||
private MaintenanceJoinListener joinListener;
|
||||
private MaintenanceMotdListener motdListener;
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "maintenance";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "Maintenance";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad(ExtensionContext context) {
|
||||
this.context = context;
|
||||
this.maintenanceService = new MaintenanceService(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
this.joinListener = new MaintenanceJoinListener(maintenanceService);
|
||||
this.motdListener = new MaintenanceMotdListener(maintenanceService);
|
||||
context.plugin().getServer().getPluginManager().registerEvents(joinListener, context.plugin());
|
||||
context.plugin().getServer().getPluginManager().registerEvents(motdListener, context.plugin());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (joinListener != null) {
|
||||
HandlerList.unregisterAll(joinListener);
|
||||
joinListener = null;
|
||||
}
|
||||
if (motdListener != null) {
|
||||
HandlerList.unregisterAll(motdListener);
|
||||
motdListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
|
||||
webRegistry.get("/api/extensions/maintenance/status", PanelPermission.VIEW_MAINTENANCE, (request, response, user) -> {
|
||||
MaintenanceService.MaintenanceSnapshot snapshot = maintenanceService.snapshot();
|
||||
return webRegistry.json(response, 200, Map.of(
|
||||
"enabled", snapshot.enabled(),
|
||||
"reason", snapshot.reason(),
|
||||
"motd", snapshot.motd(),
|
||||
"changedBy", snapshot.changedBy(),
|
||||
"changedAt", snapshot.changedAt(),
|
||||
"affectedOnlinePlayers", snapshot.affectedOnlinePlayers(),
|
||||
"bypassPermission", MaintenanceService.BYPASS_PERMISSION
|
||||
));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/maintenance/enable", PanelPermission.MANAGE_MAINTENANCE, (request, response, user) -> {
|
||||
TogglePayload payload = gson.fromJson(request.body(), TogglePayload.class);
|
||||
String reason = payload != null ? payload.reason() : null;
|
||||
String motd = payload != null ? payload.motd() : null;
|
||||
boolean kickNonStaff = payload != null && Boolean.TRUE.equals(payload.kickNonStaff());
|
||||
|
||||
int kicked;
|
||||
try {
|
||||
kicked = maintenanceService.enable(user.username(), reason, motd, kickNonStaff);
|
||||
} catch (IllegalStateException exception) {
|
||||
return webRegistry.json(response, 500, Map.of("error", "maintenance_enable_failed", "details", exception.getMessage()));
|
||||
}
|
||||
context.panelLogger().log(
|
||||
"AUDIT",
|
||||
user.username(),
|
||||
"Enabled maintenance mode" + (kickNonStaff ? " and kicked " + kicked + " player(s)" : "")
|
||||
);
|
||||
|
||||
return webRegistry.json(response, 200, toStatusPayload(true, kicked));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/maintenance/disable", PanelPermission.MANAGE_MAINTENANCE, (request, response, user) -> {
|
||||
maintenanceService.disable(user.username());
|
||||
context.panelLogger().log("AUDIT", user.username(), "Disabled maintenance mode");
|
||||
return webRegistry.json(response, 200, toStatusPayload(true, 0));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/maintenance/kick-nonstaff", PanelPermission.MANAGE_MAINTENANCE, (request, response, user) -> {
|
||||
if (!maintenanceService.isEnabled()) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "maintenance_not_enabled"));
|
||||
}
|
||||
|
||||
int kicked;
|
||||
try {
|
||||
kicked = maintenanceService.kickNonStaffPlayers();
|
||||
} catch (IllegalStateException exception) {
|
||||
return webRegistry.json(response, 500, Map.of("error", "maintenance_kick_failed", "details", exception.getMessage()));
|
||||
}
|
||||
context.panelLogger().log("AUDIT", user.username(), "Kicked " + kicked + " non-staff player(s) during maintenance");
|
||||
return webRegistry.json(response, 200, toStatusPayload(true, kicked));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ExtensionNavigationTab> navigationTabs() {
|
||||
return List.of(new ExtensionNavigationTab("server", "Maintenance", "/dashboard/maintenance"));
|
||||
}
|
||||
|
||||
private Map<String, Object> toStatusPayload(boolean ok, int kicked) {
|
||||
MaintenanceService.MaintenanceSnapshot snapshot = maintenanceService.snapshot();
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("ok", ok);
|
||||
payload.put("enabled", snapshot.enabled());
|
||||
payload.put("reason", snapshot.reason());
|
||||
payload.put("motd", snapshot.motd());
|
||||
payload.put("changedBy", snapshot.changedBy());
|
||||
payload.put("changedAt", snapshot.changedAt());
|
||||
payload.put("affectedOnlinePlayers", snapshot.affectedOnlinePlayers());
|
||||
payload.put("kicked", kicked);
|
||||
payload.put("bypassPermission", MaintenanceService.BYPASS_PERMISSION);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private record TogglePayload(String reason, String motd, Boolean kickNonStaff) {
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package de.winniepat.minePanel.extensions.maintenance;
|
||||
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerLoginEvent;
|
||||
|
||||
public final class MaintenanceJoinListener implements Listener {
|
||||
|
||||
private final MaintenanceService maintenanceService;
|
||||
|
||||
public MaintenanceJoinListener(MaintenanceService maintenanceService) {
|
||||
this.maintenanceService = maintenanceService;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onPlayerLogin(PlayerLoginEvent event) {
|
||||
if (!maintenanceService.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (maintenanceService.canBypass(event.getPlayer())) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.disallow(PlayerLoginEvent.Result.KICK_OTHER, maintenanceService.kickMessage());
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package de.winniepat.minePanel.extensions.maintenance;
|
||||
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.server.ServerListPingEvent;
|
||||
|
||||
public final class MaintenanceMotdListener implements Listener {
|
||||
|
||||
private final MaintenanceService maintenanceService;
|
||||
|
||||
public MaintenanceMotdListener(MaintenanceService maintenanceService) {
|
||||
this.maintenanceService = maintenanceService;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onServerListPing(ServerListPingEvent event) {
|
||||
if (!maintenanceService.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.setMotd(maintenanceService.motd());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package de.winniepat.minePanel.extensions.maintenance;
|
||||
|
||||
import de.winniepat.minePanel.extensions.ExtensionContext;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class MaintenanceService {
|
||||
|
||||
public static final String BYPASS_PERMISSION = "minepanel.maintenance.bypass";
|
||||
|
||||
private final ExtensionContext context;
|
||||
|
||||
private volatile boolean enabled;
|
||||
private volatile String reason = "§cServer maintenance in progress";
|
||||
private volatile String motd = "§cMaintenance mode is active";
|
||||
private volatile String changedBy = "SYSTEM";
|
||||
private volatile long changedAt = Instant.now().toEpochMilli();
|
||||
|
||||
public MaintenanceService(ExtensionContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public MaintenanceSnapshot snapshot() {
|
||||
return new MaintenanceSnapshot(enabled, reason, motd, changedBy, changedAt, countAffectedOnlinePlayers());
|
||||
}
|
||||
|
||||
public int enable(String actor, String nextReason, String nextMotd, boolean kickNonStaff) {
|
||||
enabled = true;
|
||||
reason = normalizeReason(nextReason);
|
||||
motd = normalizeMotd(nextMotd);
|
||||
changedBy = normalizeActor(actor);
|
||||
changedAt = Instant.now().toEpochMilli();
|
||||
|
||||
if (!kickNonStaff) {
|
||||
return 0;
|
||||
}
|
||||
return kickNonStaffPlayers();
|
||||
}
|
||||
|
||||
public void disable(String actor) {
|
||||
enabled = false;
|
||||
changedBy = normalizeActor(actor);
|
||||
changedAt = Instant.now().toEpochMilli();
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public String reason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public String changedBy() {
|
||||
return changedBy;
|
||||
}
|
||||
|
||||
public String motd() {
|
||||
return motd;
|
||||
}
|
||||
|
||||
public long changedAt() {
|
||||
return changedAt;
|
||||
}
|
||||
|
||||
public boolean canBypass(Player player) {
|
||||
return player != null && (player.isOp() || player.hasPermission(BYPASS_PERMISSION));
|
||||
}
|
||||
|
||||
public int kickNonStaffPlayers() {
|
||||
return runSync(this::kickNonStaffPlayersSync);
|
||||
}
|
||||
|
||||
private int kickNonStaffPlayersSync() {
|
||||
int kicked = 0;
|
||||
for (Player online : context.plugin().getServer().getOnlinePlayers()) {
|
||||
if (canBypass(online)) {
|
||||
continue;
|
||||
}
|
||||
kicked++;
|
||||
online.kickPlayer(kickMessage());
|
||||
}
|
||||
return kicked;
|
||||
}
|
||||
|
||||
public int countAffectedOnlinePlayers() {
|
||||
return runSync(this::countAffectedOnlinePlayersSync);
|
||||
}
|
||||
|
||||
private int countAffectedOnlinePlayersSync() {
|
||||
int affected = 0;
|
||||
for (Player online : context.plugin().getServer().getOnlinePlayers()) {
|
||||
if (!canBypass(online)) {
|
||||
affected++;
|
||||
}
|
||||
}
|
||||
return affected;
|
||||
}
|
||||
|
||||
public String kickMessage() {
|
||||
return "Maintenance mode is active. " + reason;
|
||||
}
|
||||
|
||||
private String normalizeReason(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "Server maintenance in progress";
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.length() > 180 ? trimmed.substring(0, 180) : trimmed;
|
||||
}
|
||||
|
||||
private String normalizeMotd(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "Maintenance mode is active";
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.length() > 120 ? trimmed.substring(0, 120) : trimmed;
|
||||
}
|
||||
|
||||
private String normalizeActor(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "SYSTEM";
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private int runSync(SyncIntTask task) {
|
||||
try {
|
||||
return context.schedulerBridge().callGlobal(task::run, 10, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("maintenance_task_interrupted", exception);
|
||||
} catch (ExecutionException | TimeoutException exception) {
|
||||
throw new IllegalStateException("maintenance_task_failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface SyncIntTask {
|
||||
int run();
|
||||
}
|
||||
|
||||
public record MaintenanceSnapshot(
|
||||
boolean enabled,
|
||||
String reason,
|
||||
String motd,
|
||||
String changedBy,
|
||||
long changedAt,
|
||||
int affectedOnlinePlayers
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
package de.winniepat.minePanel.extensions.playermanagement;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import de.winniepat.minePanel.extensions.ExtensionConfigurable;
|
||||
import de.winniepat.minePanel.extensions.ExtensionContext;
|
||||
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
|
||||
import de.winniepat.minePanel.extensions.MinePanelExtension;
|
||||
import de.winniepat.minePanel.persistence.ExtensionSettingsRepository;
|
||||
import de.winniepat.minePanel.persistence.KnownPlayer;
|
||||
import de.winniepat.minePanel.users.PanelPermission;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.HandlerList;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class PlayerManagementExtension implements MinePanelExtension, ExtensionConfigurable {
|
||||
|
||||
private final Gson gson = new Gson();
|
||||
private ExtensionContext context;
|
||||
private PlayerMuteRepository muteRepository;
|
||||
private PlayerMuteListener muteListener;
|
||||
private ExtensionSettingsRepository extensionSettingsRepository;
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "player-management";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "Player Management";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad(ExtensionContext context) {
|
||||
this.context = context;
|
||||
this.muteRepository = new PlayerMuteRepository(context.database());
|
||||
this.muteRepository.initializeSchema();
|
||||
this.extensionSettingsRepository = new ExtensionSettingsRepository(context.database());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
muteRepository.clearExpired(Instant.now().toEpochMilli());
|
||||
ChatFilterSettings settings = loadChatFilterSettings();
|
||||
|
||||
muteListener = new PlayerMuteListener(
|
||||
muteRepository,
|
||||
context.panelLogger(),
|
||||
settings.enabled(),
|
||||
settings.badWords(),
|
||||
settings.autoMuteMinutes(),
|
||||
settings.autoMuteReason(),
|
||||
settings.cancelMessage()
|
||||
);
|
||||
context.plugin().getServer().getPluginManager().registerEvents(muteListener, context.plugin());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (muteListener != null) {
|
||||
HandlerList.unregisterAll(muteListener);
|
||||
muteListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSettingsUpdated(String settingsJson) {
|
||||
ChatFilterSettings latest = parseChatFilterSettings(settingsJson);
|
||||
if (muteListener == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
muteListener.updateFilterConfig(
|
||||
latest.enabled(),
|
||||
latest.badWords(),
|
||||
latest.autoMuteMinutes(),
|
||||
latest.autoMuteReason(),
|
||||
latest.cancelMessage()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
|
||||
webRegistry.post("/api/extensions/player-management/mute", PanelPermission.MANAGE_PLAYER_MANAGEMENT, (request, response, user) -> {
|
||||
MutePayload payload = gson.fromJson(request.body(), MutePayload.class);
|
||||
if (payload == null || (isBlank(payload.uuid()) && isBlank(payload.username()))) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
|
||||
}
|
||||
|
||||
Optional<KnownPlayer> target = resolveTarget(payload.uuid(), payload.username());
|
||||
if (target.isEmpty()) {
|
||||
return webRegistry.json(response, 404, Map.of("error", "player_not_found"));
|
||||
}
|
||||
|
||||
int minutes = payload.durationMinutes() == null ? 0 : Math.max(0, Math.min(43_200, payload.durationMinutes()));
|
||||
String reason = isBlank(payload.reason()) ? "Muted by panel moderator" : payload.reason().trim();
|
||||
long now = Instant.now().toEpochMilli();
|
||||
Long expiresAt = minutes <= 0 ? null : now + minutes * 60_000L;
|
||||
|
||||
muteRepository.upsertMute(target.get().uuid(), target.get().username(), reason, user.username(), now, expiresAt);
|
||||
context.panelLogger().log("AUDIT", user.username(), "Muted " + target.get().username() + (minutes > 0 ? " for " + minutes + " minute(s)" : " permanently") + ": " + reason);
|
||||
|
||||
Player online = context.plugin().getServer().getPlayer(target.get().uuid());
|
||||
if (online != null) {
|
||||
online.sendMessage("You were muted by a moderator. Reason: " + reason);
|
||||
}
|
||||
|
||||
java.util.Map<String, Object> resultPayload = new java.util.HashMap<>();
|
||||
resultPayload.put("ok", true);
|
||||
resultPayload.put("uuid", target.get().uuid().toString());
|
||||
resultPayload.put("username", target.get().username());
|
||||
resultPayload.put("expiresAt", expiresAt);
|
||||
return webRegistry.json(response, 200, resultPayload);
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/player-management/unmute", PanelPermission.MANAGE_PLAYER_MANAGEMENT, (request, response, user) -> {
|
||||
UnmutePayload payload = gson.fromJson(request.body(), UnmutePayload.class);
|
||||
if (payload == null || (isBlank(payload.uuid()) && isBlank(payload.username()))) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
|
||||
}
|
||||
|
||||
Optional<KnownPlayer> target = resolveTarget(payload.uuid(), payload.username());
|
||||
if (target.isEmpty()) {
|
||||
return webRegistry.json(response, 404, Map.of("error", "player_not_found"));
|
||||
}
|
||||
|
||||
boolean removed = muteRepository.removeMute(target.get().uuid());
|
||||
if (!removed) {
|
||||
return webRegistry.json(response, 404, Map.of("error", "player_not_muted"));
|
||||
}
|
||||
|
||||
context.panelLogger().log("AUDIT", user.username(), "Unmuted " + target.get().username());
|
||||
return webRegistry.json(response, 200, Map.of("ok", true));
|
||||
});
|
||||
|
||||
webRegistry.get("/api/extensions/player-management/mute/:uuid", PanelPermission.VIEW_PLAYER_MANAGEMENT, (request, response, user) -> {
|
||||
UUID uuid;
|
||||
try {
|
||||
uuid = UUID.fromString(request.params("uuid"));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_uuid"));
|
||||
}
|
||||
|
||||
muteRepository.clearExpired(Instant.now().toEpochMilli());
|
||||
Optional<PlayerMute> mute = muteRepository.findByUuid(uuid);
|
||||
if (mute.isEmpty()) {
|
||||
return webRegistry.json(response, 200, Map.of("muted", false));
|
||||
}
|
||||
|
||||
return webRegistry.json(response, 200, Map.of(
|
||||
"muted", true,
|
||||
"username", mute.get().username(),
|
||||
"reason", mute.get().reason(),
|
||||
"mutedBy", mute.get().mutedBy(),
|
||||
"mutedAt", mute.get().mutedAt(),
|
||||
"expiresAt", mute.get().expiresAt()
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<KnownPlayer> resolveTarget(String rawUuid, String rawUsername) {
|
||||
if (!isBlank(rawUuid)) {
|
||||
try {
|
||||
UUID uuid = UUID.fromString(rawUuid.trim());
|
||||
Optional<KnownPlayer> known = context.knownPlayerRepository().findByUuid(uuid);
|
||||
if (known.isPresent()) {
|
||||
return known;
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Fallback to username lookup.
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBlank(rawUsername)) {
|
||||
Optional<KnownPlayer> knownByName = context.knownPlayerRepository().findByUsername(rawUsername.trim());
|
||||
if (knownByName.isPresent()) {
|
||||
return knownByName;
|
||||
}
|
||||
|
||||
Player online = context.plugin().getServer().getPlayerExact(rawUsername.trim());
|
||||
if (online != null) {
|
||||
long now = Instant.now().toEpochMilli();
|
||||
context.knownPlayerRepository().upsert(online.getUniqueId(), online.getName(), now);
|
||||
return context.knownPlayerRepository().findByUuid(online.getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private Set<String> parseBadWords(List<String> configuredWords) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
if (configuredWords == null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (String rawWord : configuredWords) {
|
||||
if (rawWord == null || rawWord.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
result.add(rawWord.trim());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private ChatFilterSettings loadChatFilterSettings() {
|
||||
String settingsJson = extensionSettingsRepository.findSettingsJson(id()).orElse("{}");
|
||||
return parseChatFilterSettings(settingsJson);
|
||||
}
|
||||
|
||||
private ChatFilterSettings parseChatFilterSettings(String settingsJson) {
|
||||
try {
|
||||
JsonElement root = gson.fromJson(settingsJson, JsonElement.class);
|
||||
if (root == null || !root.isJsonObject()) {
|
||||
return ChatFilterSettings.defaults();
|
||||
}
|
||||
|
||||
JsonObject rootObject = root.getAsJsonObject();
|
||||
JsonObject filter = rootObject.has("badWordFilter") && rootObject.get("badWordFilter").isJsonObject()
|
||||
? rootObject.getAsJsonObject("badWordFilter")
|
||||
: new JsonObject();
|
||||
|
||||
boolean enabled = booleanValue(filter, "enabled", false);
|
||||
int autoMuteMinutes = intValue(filter, "autoMuteMinutes", 15, 0, 43_200);
|
||||
String autoMuteReason = stringValue(filter, "autoMuteReason", "Inappropriate language");
|
||||
boolean cancelMessage = booleanValue(filter, "cancelMessage", true);
|
||||
Set<String> words = parseBadWords(readWords(filter));
|
||||
return new ChatFilterSettings(enabled, words, autoMuteMinutes, autoMuteReason, cancelMessage);
|
||||
} catch (Exception exception) {
|
||||
context.plugin().getLogger().warning("Could not parse player-management extension settings: " + exception.getMessage());
|
||||
return ChatFilterSettings.defaults();
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> readWords(JsonObject filter) {
|
||||
if (filter == null || !filter.has("words") || !filter.get("words").isJsonArray()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
JsonArray array = filter.getAsJsonArray("words");
|
||||
java.util.ArrayList<String> words = new java.util.ArrayList<>();
|
||||
for (JsonElement element : array) {
|
||||
if (element == null || !element.isJsonPrimitive()) {
|
||||
continue;
|
||||
}
|
||||
words.add(element.getAsString());
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
private boolean booleanValue(JsonObject object, String key, boolean fallback) {
|
||||
if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return object.get(key).getAsBoolean();
|
||||
} catch (Exception ignored) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private int intValue(JsonObject object, String key, int fallback, int min, int max) {
|
||||
if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
int value = object.get(key).getAsInt();
|
||||
return Math.max(min, Math.min(max, value));
|
||||
} catch (Exception ignored) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private String stringValue(JsonObject object, String key, String fallback) {
|
||||
if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()) {
|
||||
return fallback;
|
||||
}
|
||||
String value = object.get(key).getAsString();
|
||||
return (value == null || value.isBlank()) ? fallback : value.trim();
|
||||
}
|
||||
|
||||
private record ChatFilterSettings(boolean enabled, Set<String> badWords, int autoMuteMinutes, String autoMuteReason, boolean cancelMessage) {
|
||||
private static ChatFilterSettings defaults() {
|
||||
return new ChatFilterSettings(false, Set.of(), 15, "Inappropriate language", true);
|
||||
}
|
||||
}
|
||||
|
||||
private record MutePayload(String uuid, String username, Integer durationMinutes, String reason) {
|
||||
}
|
||||
|
||||
private record UnmutePayload(String uuid, String username) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.winniepat.minePanel.extensions.playermanagement;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record PlayerMute(
|
||||
UUID uuid,
|
||||
String username,
|
||||
String reason,
|
||||
String mutedBy,
|
||||
long mutedAt,
|
||||
Long expiresAt
|
||||
) {
|
||||
public boolean isExpired(long nowMillis) {
|
||||
return expiresAt != null && expiresAt > 0 && nowMillis >= expiresAt;
|
||||
}
|
||||
|
||||
public boolean isActive(long nowMillis) {
|
||||
return !isExpired(nowMillis);
|
||||
}
|
||||
}
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package de.winniepat.minePanel.extensions.playermanagement;
|
||||
|
||||
import de.winniepat.minePanel.logs.PanelLogger;
|
||||
import io.papermc.paper.event.player.AsyncChatEvent;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.AsyncPlayerChatEvent;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class PlayerMuteListener implements Listener {
|
||||
|
||||
private static final PlainTextComponentSerializer PLAIN_TEXT_SERIALIZER = PlainTextComponentSerializer.plainText();
|
||||
|
||||
private final PlayerMuteRepository muteRepository;
|
||||
private final PanelLogger panelLogger;
|
||||
private volatile boolean badWordFilterEnabled;
|
||||
private volatile Set<String> badWords;
|
||||
private volatile int autoMuteMinutes;
|
||||
private volatile String autoMuteReason;
|
||||
private volatile boolean cancelBlockedMessage;
|
||||
|
||||
public PlayerMuteListener(
|
||||
PlayerMuteRepository muteRepository,
|
||||
PanelLogger panelLogger,
|
||||
boolean badWordFilterEnabled,
|
||||
Set<String> badWords,
|
||||
int autoMuteMinutes,
|
||||
String autoMuteReason,
|
||||
boolean cancelBlockedMessage
|
||||
) {
|
||||
this.muteRepository = muteRepository;
|
||||
this.panelLogger = panelLogger;
|
||||
updateFilterConfig(badWordFilterEnabled, badWords, autoMuteMinutes, autoMuteReason, cancelBlockedMessage);
|
||||
}
|
||||
|
||||
public void updateFilterConfig(
|
||||
boolean badWordFilterEnabled,
|
||||
Set<String> badWords,
|
||||
int autoMuteMinutes,
|
||||
String autoMuteReason,
|
||||
boolean cancelBlockedMessage
|
||||
) {
|
||||
this.badWordFilterEnabled = badWordFilterEnabled;
|
||||
this.badWords = badWords == null ? Set.of() : new HashSet<>(badWords);
|
||||
this.autoMuteMinutes = Math.max(0, autoMuteMinutes);
|
||||
this.autoMuteReason = (autoMuteReason == null || autoMuteReason.isBlank())
|
||||
? "Inappropriate language"
|
||||
: autoMuteReason.trim();
|
||||
this.cancelBlockedMessage = cancelBlockedMessage;
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true)
|
||||
public void onAsyncPlayerChat(AsyncPlayerChatEvent event) {
|
||||
processChatMessage(event.getPlayer(), event.getMessage(), event::setCancelled);
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true)
|
||||
public void onAsyncChat(AsyncChatEvent event) {
|
||||
processChatMessage(event.getPlayer(), PLAIN_TEXT_SERIALIZER.serialize(event.message()), event::setCancelled);
|
||||
}
|
||||
|
||||
private void processChatMessage(Player player, String message, CancelHandler cancelHandler) {
|
||||
long now = Instant.now().toEpochMilli();
|
||||
Optional<PlayerMute> mute = muteRepository.findByUuid(player.getUniqueId());
|
||||
if (mute.isEmpty()) {
|
||||
handleBadWordFilter(player, message, now, cancelHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerMute activeMute = mute.get();
|
||||
if (activeMute.isExpired(now)) {
|
||||
muteRepository.removeMute(activeMute.uuid());
|
||||
handleBadWordFilter(player, message, now, cancelHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelHandler.cancel(true);
|
||||
if (activeMute.expiresAt() == null) {
|
||||
player.sendMessage(ChatColor.RED + "You are muted. Reason: " + activeMute.reason());
|
||||
return;
|
||||
}
|
||||
|
||||
long secondsLeft = Math.max(1L, (activeMute.expiresAt() - now) / 1000L);
|
||||
player.sendMessage(ChatColor.RED + "You are muted for another " + secondsLeft + "s. Reason: " + activeMute.reason());
|
||||
}
|
||||
|
||||
private void handleBadWordFilter(Player player, String message, long now, CancelHandler cancelHandler) {
|
||||
if (!badWordFilterEnabled || badWords.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.hasPermission("minepanel.chatfilter.bypass") || player.isOp()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String matchedWord = findMatchedBadWord(message);
|
||||
if (matchedWord == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long expiresAt = autoMuteMinutes <= 0 ? null : now + autoMuteMinutes * 60_000L;
|
||||
muteRepository.upsertMute(
|
||||
player.getUniqueId(),
|
||||
player.getName(),
|
||||
autoMuteReason,
|
||||
"AUTO_MOD",
|
||||
now,
|
||||
expiresAt
|
||||
);
|
||||
|
||||
if (cancelBlockedMessage) {
|
||||
cancelHandler.cancel(true);
|
||||
}
|
||||
|
||||
panelLogger.log(
|
||||
"SECURITY",
|
||||
"CHAT_FILTER",
|
||||
"Auto-muted " + player.getName() + " for bad language (matched: " + matchedWord + ")"
|
||||
);
|
||||
|
||||
if (expiresAt == null) {
|
||||
player.sendMessage(ChatColor.RED + "Your message contained blocked language. You have been muted. Reason: " + autoMuteReason);
|
||||
return;
|
||||
}
|
||||
|
||||
player.sendMessage(ChatColor.RED + "Your message contained blocked language. You have been muted for " + autoMuteMinutes + " minute(s). Reason: " + autoMuteReason);
|
||||
}
|
||||
|
||||
private String findMatchedBadWord(String message) {
|
||||
if (message == null || message.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String lower = message.toLowerCase(Locale.ROOT);
|
||||
for (String badWord : badWords) {
|
||||
if (badWord == null || badWord.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String normalizedBadWord = badWord.toLowerCase(Locale.ROOT).trim();
|
||||
String pattern = "(^|[^a-z0-9])" + Pattern.quote(normalizedBadWord) + "([^a-z0-9]|$)";
|
||||
if (Pattern.compile(pattern).matcher(lower).find()) {
|
||||
return badWord;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface CancelHandler {
|
||||
void cancel(boolean cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package de.winniepat.minePanel.extensions.playermanagement;
|
||||
|
||||
import de.winniepat.minePanel.persistence.Database;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
|
||||
public final class PlayerMuteRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public PlayerMuteRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public void initializeSchema() {
|
||||
String sql = "CREATE TABLE IF NOT EXISTS player_mutes ("
|
||||
+ "uuid TEXT PRIMARY KEY,"
|
||||
+ "username TEXT NOT NULL,"
|
||||
+ "reason TEXT NOT NULL,"
|
||||
+ "muted_by TEXT NOT NULL,"
|
||||
+ "muted_at INTEGER NOT NULL,"
|
||||
+ "expires_at INTEGER"
|
||||
+ ")";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute(sql);
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not initialize mute schema", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void upsertMute(UUID uuid, String username, String reason, String mutedBy, long mutedAt, Long expiresAt) {
|
||||
String sql = "INSERT INTO player_mutes(uuid, username, reason, muted_by, muted_at, expires_at) VALUES (?, ?, ?, ?, ?, ?) "
|
||||
+ "ON CONFLICT(uuid) DO UPDATE SET "
|
||||
+ "username = excluded.username, "
|
||||
+ "reason = excluded.reason, "
|
||||
+ "muted_by = excluded.muted_by, "
|
||||
+ "muted_at = excluded.muted_at, "
|
||||
+ "expires_at = excluded.expires_at";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, uuid.toString());
|
||||
statement.setString(2, username == null ? "" : username);
|
||||
statement.setString(3, reason == null ? "" : reason);
|
||||
statement.setString(4, mutedBy == null ? "" : mutedBy);
|
||||
statement.setLong(5, mutedAt);
|
||||
if (expiresAt == null || expiresAt <= 0) {
|
||||
statement.setNull(6, Types.BIGINT);
|
||||
} else {
|
||||
statement.setLong(6, expiresAt);
|
||||
}
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not save mute", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<PlayerMute> findByUuid(UUID uuid) {
|
||||
String sql = "SELECT uuid, username, reason, muted_by, muted_at, expires_at FROM player_mutes WHERE uuid = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, uuid.toString());
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return Optional.of(read(resultSet));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not read mute", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean removeMute(UUID uuid) {
|
||||
String sql = "DELETE FROM player_mutes WHERE uuid = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, uuid.toString());
|
||||
return statement.executeUpdate() > 0;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not delete mute", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public int clearExpired(long nowMillis) {
|
||||
String sql = "DELETE FROM player_mutes WHERE expires_at IS NOT NULL AND expires_at > 0 AND expires_at <= ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, nowMillis);
|
||||
return statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not clear expired mutes", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private PlayerMute read(ResultSet resultSet) throws SQLException {
|
||||
long rawExpires = resultSet.getLong("expires_at");
|
||||
Long expiresAt = resultSet.wasNull() ? null : rawExpires;
|
||||
return new PlayerMute(
|
||||
UUID.fromString(resultSet.getString("uuid")),
|
||||
resultSet.getString("username"),
|
||||
resultSet.getString("reason"),
|
||||
resultSet.getString("muted_by"),
|
||||
resultSet.getLong("muted_at"),
|
||||
expiresAt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package de.winniepat.minePanel.extensions.playerstats;
|
||||
|
||||
import de.winniepat.minePanel.extensions.ExtensionContext;
|
||||
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
|
||||
import de.winniepat.minePanel.extensions.MinePanelExtension;
|
||||
import de.winniepat.minePanel.users.PanelPermission;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.Statistic;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class PlayerStatsExtension implements MinePanelExtension {
|
||||
|
||||
private ExtensionContext context;
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "player-stats";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "Player Stats";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad(ExtensionContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
|
||||
webRegistry.get("/api/extensions/player-stats/player/:uuid", PanelPermission.VIEW_PLAYER_STATS, (request, response, user) -> {
|
||||
UUID playerUuid;
|
||||
try {
|
||||
playerUuid = UUID.fromString(request.params("uuid"));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_uuid"));
|
||||
}
|
||||
|
||||
StatsSnapshot snapshot;
|
||||
try {
|
||||
snapshot = fetchStats(playerUuid);
|
||||
} catch (Exception exception) {
|
||||
return webRegistry.json(response, 500, Map.of(
|
||||
"available", false,
|
||||
"error", "player_stats_lookup_failed",
|
||||
"details", exception.getClass().getSimpleName()
|
||||
));
|
||||
}
|
||||
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("available", true);
|
||||
payload.put("kills", snapshot.kills());
|
||||
payload.put("deaths", snapshot.deaths());
|
||||
payload.put("economyAvailable", snapshot.economyAvailable());
|
||||
payload.put("balance", snapshot.balance());
|
||||
payload.put("balanceFormatted", snapshot.balanceFormatted());
|
||||
payload.put("economyProvider", snapshot.economyProvider());
|
||||
payload.put("generatedAt", System.currentTimeMillis());
|
||||
return webRegistry.json(response, 200, payload);
|
||||
});
|
||||
}
|
||||
|
||||
private StatsSnapshot fetchStats(UUID playerUuid) throws ExecutionException, InterruptedException, TimeoutException {
|
||||
return context.schedulerBridge().callGlobal(() -> {
|
||||
OfflinePlayer offlinePlayer = context.plugin().getServer().getOfflinePlayer(playerUuid);
|
||||
int kills = safeStatistic(offlinePlayer, Statistic.PLAYER_KILLS);
|
||||
int deaths = safeStatistic(offlinePlayer, Statistic.DEATHS);
|
||||
|
||||
EconomyLookup economy = lookupEconomy(offlinePlayer);
|
||||
return new StatsSnapshot(kills, deaths, economy.available(), economy.balance(), economy.formatted(), economy.provider());
|
||||
}, 2, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private int safeStatistic(OfflinePlayer player, Statistic statistic) {
|
||||
try {
|
||||
return player.getStatistic(statistic);
|
||||
} catch (Exception ignored) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private EconomyLookup lookupEconomy(OfflinePlayer player) {
|
||||
try {
|
||||
Class<?> economyClass = Class.forName("net.milkbowl.vault.economy.Economy");
|
||||
Object registration = context.plugin().getServer().getServicesManager().getRegistration(economyClass);
|
||||
if (registration == null) {
|
||||
return EconomyLookup.unavailable();
|
||||
}
|
||||
|
||||
Method getProvider = registration.getClass().getMethod("getProvider");
|
||||
Object provider = getProvider.invoke(registration);
|
||||
if (provider == null) {
|
||||
return EconomyLookup.unavailable();
|
||||
}
|
||||
|
||||
String providerName;
|
||||
try {
|
||||
Method getName = provider.getClass().getMethod("getName");
|
||||
providerName = String.valueOf(getName.invoke(provider));
|
||||
} catch (Exception ignored) {
|
||||
providerName = provider.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
Double balance = invokeBalance(provider, player);
|
||||
if (balance == null) {
|
||||
return new EconomyLookup(true, null, "-", providerName);
|
||||
}
|
||||
|
||||
String formatted = formatBalance(provider, balance);
|
||||
return new EconomyLookup(true, balance, formatted, providerName);
|
||||
} catch (ClassNotFoundException ignored) {
|
||||
return EconomyLookup.unavailable();
|
||||
} catch (Exception ignored) {
|
||||
return EconomyLookup.unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private Double invokeBalance(Object provider, OfflinePlayer player) {
|
||||
try {
|
||||
Method getBalanceOffline = provider.getClass().getMethod("getBalance", OfflinePlayer.class);
|
||||
Object value = getBalanceOffline.invoke(provider, player);
|
||||
if (value instanceof Number number) {
|
||||
return number.doubleValue();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Fallback below.
|
||||
}
|
||||
|
||||
try {
|
||||
Method getBalanceString = provider.getClass().getMethod("getBalance", String.class);
|
||||
String playerName = player.getName();
|
||||
if (playerName == null || playerName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Object value = getBalanceString.invoke(provider, playerName);
|
||||
if (value instanceof Number number) {
|
||||
return number.doubleValue();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// No compatible method available.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private String formatBalance(Object provider, double value) {
|
||||
try {
|
||||
Method formatMethod = provider.getClass().getMethod("format", double.class);
|
||||
Object formatted = formatMethod.invoke(provider, value);
|
||||
if (formatted != null) {
|
||||
return String.valueOf(formatted);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Fallback below.
|
||||
}
|
||||
|
||||
return String.format(Locale.US, "%.2f", value);
|
||||
}
|
||||
|
||||
private record StatsSnapshot(
|
||||
int kills,
|
||||
int deaths,
|
||||
boolean economyAvailable,
|
||||
Double balance,
|
||||
String balanceFormatted,
|
||||
String economyProvider
|
||||
) {
|
||||
}
|
||||
|
||||
private record EconomyLookup(boolean available, Double balance, String formatted, String provider) {
|
||||
private static EconomyLookup unavailable() {
|
||||
return new EconomyLookup(false, null, "-", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.winniepat.minePanel.extensions.reports;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record PlayerReport(
|
||||
long id,
|
||||
UUID reporterUuid,
|
||||
String reporterName,
|
||||
UUID suspectUuid,
|
||||
String suspectName,
|
||||
String reason,
|
||||
String status,
|
||||
long createdAt,
|
||||
String reviewedBy,
|
||||
long reviewedAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.winniepat.minePanel.extensions.reports;
|
||||
|
||||
import de.winniepat.minePanel.logs.PanelLogger;
|
||||
import de.winniepat.minePanel.persistence.KnownPlayerRepository;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
|
||||
public final class ReportCommand implements CommandExecutor {
|
||||
|
||||
private final ReportRepository reportRepository;
|
||||
private final KnownPlayerRepository knownPlayerRepository;
|
||||
private final PanelLogger panelLogger;
|
||||
|
||||
public ReportCommand(ReportRepository reportRepository, KnownPlayerRepository knownPlayerRepository, PanelLogger panelLogger) {
|
||||
this.reportRepository = reportRepository;
|
||||
this.knownPlayerRepository = knownPlayerRepository;
|
||||
this.panelLogger = panelLogger;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player reporter)) {
|
||||
sender.sendMessage(ChatColor.RED + "Only players can create reports.");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.length < 2) {
|
||||
sender.sendMessage(ChatColor.YELLOW + "Usage: /report <player> <reason>");
|
||||
return true;
|
||||
}
|
||||
|
||||
Player suspect = reporter.getServer().getPlayerExact(args[0]);
|
||||
if (suspect == null) {
|
||||
sender.sendMessage(ChatColor.RED + "The reported player must currently be online.");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (suspect.getUniqueId().equals(reporter.getUniqueId())) {
|
||||
sender.sendMessage(ChatColor.RED + "You cannot report yourself.");
|
||||
return true;
|
||||
}
|
||||
|
||||
String reason = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).trim();
|
||||
if (reason.isBlank()) {
|
||||
sender.sendMessage(ChatColor.RED + "Please provide a reason.");
|
||||
return true;
|
||||
}
|
||||
|
||||
long now = Instant.now().toEpochMilli();
|
||||
knownPlayerRepository.upsert(reporter.getUniqueId(), reporter.getName(), now);
|
||||
knownPlayerRepository.upsert(suspect.getUniqueId(), suspect.getName(), now);
|
||||
|
||||
long reportId = reportRepository.createReport(
|
||||
reporter.getUniqueId(),
|
||||
reporter.getName(),
|
||||
suspect.getUniqueId(),
|
||||
suspect.getName(),
|
||||
reason,
|
||||
now
|
||||
);
|
||||
|
||||
sender.sendMessage(ChatColor.GREEN + "Report submitted (#" + reportId + ") for " + suspect.getName() + ".");
|
||||
panelLogger.log("REPORT", reporter.getName(), "Created report #" + reportId + " against " + suspect.getName() + ": " + reason);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package de.winniepat.minePanel.extensions.reports;
|
||||
|
||||
import de.winniepat.minePanel.persistence.Database;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
|
||||
public final class ReportRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public ReportRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public void initializeSchema() {
|
||||
String sql = "CREATE TABLE IF NOT EXISTS player_reports ("
|
||||
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
+ "reporter_uuid TEXT NOT NULL,"
|
||||
+ "reporter_name TEXT NOT NULL,"
|
||||
+ "suspect_uuid TEXT NOT NULL,"
|
||||
+ "suspect_name TEXT NOT NULL,"
|
||||
+ "reason TEXT NOT NULL,"
|
||||
+ "status TEXT NOT NULL,"
|
||||
+ "created_at INTEGER NOT NULL,"
|
||||
+ "reviewed_by TEXT NOT NULL DEFAULT '',"
|
||||
+ "reviewed_at INTEGER NOT NULL DEFAULT 0"
|
||||
+ ")";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute(sql);
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not initialize report schema", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public long createReport(UUID reporterUuid, String reporterName, UUID suspectUuid, String suspectName, String reason, long createdAt) {
|
||||
String sql = "INSERT INTO player_reports(reporter_uuid, reporter_name, suspect_uuid, suspect_name, reason, status, created_at, reviewed_by, reviewed_at) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, 'OPEN', ?, '', 0)";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||
statement.setString(1, reporterUuid.toString());
|
||||
statement.setString(2, reporterName);
|
||||
statement.setString(3, suspectUuid.toString());
|
||||
statement.setString(4, suspectName);
|
||||
statement.setString(5, reason == null ? "" : reason);
|
||||
statement.setLong(6, createdAt);
|
||||
statement.executeUpdate();
|
||||
|
||||
try (ResultSet generatedKeys = statement.getGeneratedKeys()) {
|
||||
if (generatedKeys.next()) {
|
||||
return generatedKeys.getLong(1);
|
||||
}
|
||||
}
|
||||
return 0L;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not create player report", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public List<PlayerReport> listReports(String status) {
|
||||
boolean filterStatus = status != null && !status.isBlank();
|
||||
String sql = filterStatus
|
||||
? "SELECT * FROM player_reports WHERE status = ? ORDER BY created_at DESC, id DESC"
|
||||
: "SELECT * FROM player_reports ORDER BY created_at DESC, id DESC";
|
||||
|
||||
List<PlayerReport> reports = new ArrayList<>();
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
if (filterStatus) {
|
||||
statement.setString(1, status.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
reports.add(read(resultSet));
|
||||
}
|
||||
}
|
||||
return reports;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not list reports", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<PlayerReport> findById(long id) {
|
||||
String sql = "SELECT * FROM player_reports WHERE id = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, id);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return Optional.of(read(resultSet));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not read report", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean markResolved(long id, String reviewedBy, long reviewedAt) {
|
||||
String sql = "UPDATE player_reports SET status = 'RESOLVED', reviewed_by = ?, reviewed_at = ? WHERE id = ? AND status = 'OPEN'";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, reviewedBy == null ? "" : reviewedBy);
|
||||
statement.setLong(2, reviewedAt);
|
||||
statement.setLong(3, id);
|
||||
return statement.executeUpdate() > 0;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not resolve report", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private PlayerReport read(ResultSet resultSet) throws SQLException {
|
||||
return new PlayerReport(
|
||||
resultSet.getLong("id"),
|
||||
UUID.fromString(resultSet.getString("reporter_uuid")),
|
||||
resultSet.getString("reporter_name"),
|
||||
UUID.fromString(resultSet.getString("suspect_uuid")),
|
||||
resultSet.getString("suspect_name"),
|
||||
resultSet.getString("reason"),
|
||||
resultSet.getString("status"),
|
||||
resultSet.getLong("created_at"),
|
||||
resultSet.getString("reviewed_by"),
|
||||
resultSet.getLong("reviewed_at")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package de.winniepat.minePanel.extensions.reports;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import de.winniepat.minePanel.extensions.*;
|
||||
import de.winniepat.minePanel.logs.PanelLogger;
|
||||
import de.winniepat.minePanel.persistence.KnownPlayerRepository;
|
||||
import de.winniepat.minePanel.users.PanelPermission;
|
||||
import org.bukkit.BanList;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class ReportSystemExtension implements MinePanelExtension {
|
||||
|
||||
private final Gson gson = new Gson();
|
||||
private ExtensionContext context;
|
||||
private ReportRepository reportRepository;
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "report-system";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "Report System";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad(ExtensionContext context) {
|
||||
this.context = context;
|
||||
this.reportRepository = new ReportRepository(context.database());
|
||||
this.reportRepository.initializeSchema();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
KnownPlayerRepository knownPlayerRepository = context.knownPlayerRepository();
|
||||
PanelLogger panelLogger = context.panelLogger();
|
||||
boolean registered = context.commandRegistry().register(
|
||||
id(),
|
||||
"report",
|
||||
"Report a player to MinePanel moderators",
|
||||
"/report <player> <reason>",
|
||||
"minepanel.report",
|
||||
List.of(),
|
||||
new ReportCommand(reportRepository, knownPlayerRepository, panelLogger)
|
||||
);
|
||||
|
||||
if (!registered) {
|
||||
context.plugin().getLogger().warning("Could not register /report command for report extension.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
|
||||
webRegistry.get("/api/extensions/reports", PanelPermission.VIEW_REPORTS, (request, response, user) -> {
|
||||
String status = request.queryParams("status");
|
||||
List<Map<String, Object>> reports = reportRepository.listReports(status).stream().map(this::toReportPayload).toList();
|
||||
return webRegistry.json(response, 200, Map.of("reports", reports));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/reports/:id/resolve", PanelPermission.MANAGE_REPORTS, (request, response, user) -> {
|
||||
long reportId = parseReportId(request.params("id"));
|
||||
if (reportId <= 0) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_report_id"));
|
||||
}
|
||||
|
||||
boolean updated = reportRepository.markResolved(reportId, user.username(), Instant.now().toEpochMilli());
|
||||
if (!updated) {
|
||||
return webRegistry.json(response, 404, Map.of("error", "report_not_found_or_closed"));
|
||||
}
|
||||
|
||||
context.panelLogger().log("AUDIT", user.username(), "Resolved player report #" + reportId);
|
||||
return webRegistry.json(response, 200, Map.of("ok", true));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/reports/:id/ban", PanelPermission.MANAGE_REPORTS, (request, response, user) -> {
|
||||
long reportId = parseReportId(request.params("id"));
|
||||
if (reportId <= 0) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_report_id"));
|
||||
}
|
||||
|
||||
Optional<PlayerReport> report = reportRepository.findById(reportId);
|
||||
if (report.isEmpty()) {
|
||||
return webRegistry.json(response, 404, Map.of("error", "report_not_found"));
|
||||
}
|
||||
|
||||
BanPayload payload = gson.fromJson(request.body(), BanPayload.class);
|
||||
Integer durationMinutes = payload == null ? null : payload.durationMinutes();
|
||||
String reason = payload == null || isBlank(payload.reason())
|
||||
? "Banned by report review (#" + reportId + ")"
|
||||
: payload.reason().trim();
|
||||
|
||||
BanResult banResult = banPlayer(report.get(), durationMinutes, reason, user.username());
|
||||
if (!banResult.success()) {
|
||||
return webRegistry.json(response, 500, Map.of("error", "ban_failed", "details", banResult.error()));
|
||||
}
|
||||
|
||||
reportRepository.markResolved(reportId, user.username(), Instant.now().toEpochMilli());
|
||||
context.panelLogger().log("AUDIT", user.username(), "Banned " + report.get().suspectName() + " from report #" + reportId + ": " + reason);
|
||||
java.util.Map<String, Object> resultPayload = new java.util.HashMap<>();
|
||||
resultPayload.put("ok", true);
|
||||
resultPayload.put("username", report.get().suspectName());
|
||||
resultPayload.put("expiresAt", banResult.expiresAt());
|
||||
return webRegistry.json(response, 200, resultPayload);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ExtensionNavigationTab> navigationTabs() {
|
||||
return List.of(new ExtensionNavigationTab("server", "Reports", "/dashboard/reports"));
|
||||
}
|
||||
|
||||
private Map<String, Object> toReportPayload(PlayerReport report) {
|
||||
return Map.of(
|
||||
"id", report.id(),
|
||||
"reporterUuid", report.reporterUuid().toString(),
|
||||
"reporterName", report.reporterName(),
|
||||
"suspectUuid", report.suspectUuid().toString(),
|
||||
"suspectName", report.suspectName(),
|
||||
"reason", report.reason(),
|
||||
"status", report.status(),
|
||||
"createdAt", report.createdAt(),
|
||||
"reviewedBy", report.reviewedBy(),
|
||||
"reviewedAt", report.reviewedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private long parseReportId(String rawId) {
|
||||
try {
|
||||
return Long.parseLong(rawId);
|
||||
} catch (Exception ignored) {
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
private BanResult banPlayer(PlayerReport report, Integer durationMinutes, String reason, String actor) {
|
||||
try {
|
||||
return context.schedulerBridge().callGlobal(() -> {
|
||||
Date expiresAt = null;
|
||||
if (durationMinutes != null && durationMinutes > 0) {
|
||||
int clampedMinutes = Math.min(durationMinutes, 43_200);
|
||||
expiresAt = Date.from(Instant.now().plusSeconds(clampedMinutes * 60L));
|
||||
}
|
||||
|
||||
context.plugin().getServer().getBanList(BanList.Type.NAME)
|
||||
.addBan(report.suspectName(), reason, expiresAt, "MinePanelReport:" + actor);
|
||||
|
||||
Player online = context.plugin().getServer().getPlayer(report.suspectUuid());
|
||||
if (online == null) {
|
||||
online = context.plugin().getServer().getPlayerExact(report.suspectName());
|
||||
}
|
||||
if (online != null) {
|
||||
online.kickPlayer("Banned. Reason: " + reason);
|
||||
}
|
||||
|
||||
return new BanResult(true, expiresAt == null ? null : expiresAt.getTime(), "");
|
||||
}, 2, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
return new BanResult(false, null, "interrupted");
|
||||
} catch (ExecutionException | TimeoutException exception) {
|
||||
return new BanResult(false, null, exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private record BanPayload(Integer durationMinutes, String reason) {
|
||||
}
|
||||
|
||||
private record BanResult(boolean success, Long expiresAt, String error) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.winniepat.minePanel.extensions.tickets;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record PlayerTicket(
|
||||
long id,
|
||||
UUID creatorUuid,
|
||||
String creatorName,
|
||||
String category,
|
||||
String description,
|
||||
String status,
|
||||
long createdAt,
|
||||
long updatedAt,
|
||||
String handledBy,
|
||||
long handledAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.winniepat.minePanel.extensions.tickets;
|
||||
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
public final class TicketCommand implements CommandExecutor {
|
||||
|
||||
private final TicketMenuListener menuListener;
|
||||
|
||||
public TicketCommand(TicketMenuListener menuListener) {
|
||||
this.menuListener = menuListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage(ChatColor.RED + "Only players can create support tickets.");
|
||||
return true;
|
||||
}
|
||||
|
||||
menuListener.openMenu(player);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.winniepat.minePanel.extensions.tickets;
|
||||
|
||||
import de.winniepat.minePanel.logs.PanelLogger;
|
||||
import de.winniepat.minePanel.persistence.KnownPlayerRepository;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class TicketMenuListener implements Listener {
|
||||
|
||||
private static final String MENU_TITLE = ChatColor.DARK_AQUA + "MinePanel Tickets";
|
||||
|
||||
private final TicketRepository ticketRepository;
|
||||
private final KnownPlayerRepository knownPlayerRepository;
|
||||
private final PanelLogger panelLogger;
|
||||
|
||||
public TicketMenuListener(TicketRepository ticketRepository, KnownPlayerRepository knownPlayerRepository, PanelLogger panelLogger) {
|
||||
this.ticketRepository = ticketRepository;
|
||||
this.knownPlayerRepository = knownPlayerRepository;
|
||||
this.panelLogger = panelLogger;
|
||||
}
|
||||
|
||||
public void openMenu(Player player) {
|
||||
Inventory menu = Bukkit.createInventory(null, 27, MENU_TITLE);
|
||||
|
||||
menu.setItem(11, createTicketItem(Material.REDSTONE, ChatColor.RED + "Technical Issue", List.of(ChatColor.GRAY + "Server lag, crashes or bugs")));
|
||||
menu.setItem(13, createTicketItem(Material.PAPER, ChatColor.AQUA + "Question / Help", List.of(ChatColor.GRAY + "Need support from the team")));
|
||||
menu.setItem(15, createTicketItem(Material.BOOK, ChatColor.GREEN + "Other", List.of(ChatColor.GRAY + "General support request")));
|
||||
menu.setItem(22, createTicketItem(Material.BARRIER, ChatColor.RED + "Close", List.of(ChatColor.GRAY + "Close this menu")));
|
||||
|
||||
player.openInventory(menu);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onInventoryClick(InventoryClickEvent event) {
|
||||
if (!(event.getWhoClicked() instanceof Player player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!MENU_TITLE.equals(event.getView().getTitle())) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.setCancelled(true);
|
||||
int slot = event.getRawSlot();
|
||||
if (slot < 0 || slot >= event.getView().getTopInventory().getSize()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<Integer, TicketTemplate> templates = Map.of(
|
||||
11, new TicketTemplate("Technical Issue", "Reported from in-game menu: technical issue"),
|
||||
13, new TicketTemplate("Question / Help", "Reported from in-game menu: question or support needed"),
|
||||
15, new TicketTemplate("Other", "Reported from in-game menu: general support request")
|
||||
);
|
||||
|
||||
if (slot == 22) {
|
||||
player.closeInventory();
|
||||
return;
|
||||
}
|
||||
|
||||
TicketTemplate template = templates.get(slot);
|
||||
if (template == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long now = Instant.now().toEpochMilli();
|
||||
knownPlayerRepository.upsert(player.getUniqueId(), player.getName(), now);
|
||||
|
||||
long ticketId = ticketRepository.createTicket(
|
||||
player.getUniqueId(),
|
||||
player.getName(),
|
||||
template.category(),
|
||||
template.description(),
|
||||
now
|
||||
);
|
||||
|
||||
player.closeInventory();
|
||||
player.sendMessage(ChatColor.GREEN + "Ticket submitted (#" + ticketId + ") in category " + template.category() + ".");
|
||||
panelLogger.log("TICKET", player.getName(), "Created ticket #" + ticketId + " [" + template.category() + "] " + template.description());
|
||||
}
|
||||
|
||||
private ItemStack createTicketItem(Material material, String name, List<String> lore) {
|
||||
ItemStack item = new ItemStack(material);
|
||||
ItemMeta meta = item.getItemMeta();
|
||||
if (meta != null) {
|
||||
meta.setDisplayName(name);
|
||||
meta.setLore(lore);
|
||||
item.setItemMeta(meta);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
private record TicketTemplate(String category, String description) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.winniepat.minePanel.extensions.tickets;
|
||||
|
||||
import de.winniepat.minePanel.persistence.Database;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
|
||||
public final class TicketRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public TicketRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public void initializeSchema() {
|
||||
String sql = "CREATE TABLE IF NOT EXISTS player_tickets ("
|
||||
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
+ "creator_uuid TEXT NOT NULL,"
|
||||
+ "creator_name TEXT NOT NULL,"
|
||||
+ "category TEXT NOT NULL,"
|
||||
+ "description TEXT NOT NULL,"
|
||||
+ "status TEXT NOT NULL,"
|
||||
+ "created_at INTEGER NOT NULL,"
|
||||
+ "updated_at INTEGER NOT NULL,"
|
||||
+ "handled_by TEXT NOT NULL DEFAULT '',"
|
||||
+ "handled_at INTEGER NOT NULL DEFAULT 0"
|
||||
+ ")";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute(sql);
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not initialize ticket schema", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public long createTicket(UUID creatorUuid, String creatorName, String category, String description, long createdAt) {
|
||||
String sql = "INSERT INTO player_tickets(creator_uuid, creator_name, category, description, status, created_at, updated_at, handled_by, handled_at) "
|
||||
+ "VALUES (?, ?, ?, ?, 'OPEN', ?, ?, '', 0)";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||
statement.setString(1, creatorUuid.toString());
|
||||
statement.setString(2, creatorName == null ? "Unknown" : creatorName);
|
||||
statement.setString(3, category == null || category.isBlank() ? "Other" : category.trim());
|
||||
statement.setString(4, description == null ? "" : description.trim());
|
||||
statement.setLong(5, createdAt);
|
||||
statement.setLong(6, createdAt);
|
||||
statement.executeUpdate();
|
||||
|
||||
try (ResultSet generatedKeys = statement.getGeneratedKeys()) {
|
||||
if (generatedKeys.next()) {
|
||||
return generatedKeys.getLong(1);
|
||||
}
|
||||
}
|
||||
return 0L;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not create ticket", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public List<PlayerTicket> listTickets(String status) {
|
||||
boolean filterStatus = status != null && !status.isBlank();
|
||||
String sql = filterStatus
|
||||
? "SELECT * FROM player_tickets WHERE status = ? ORDER BY created_at DESC, id DESC"
|
||||
: "SELECT * FROM player_tickets ORDER BY created_at DESC, id DESC";
|
||||
|
||||
List<PlayerTicket> tickets = new ArrayList<>();
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
if (filterStatus) {
|
||||
statement.setString(1, status.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
tickets.add(read(resultSet));
|
||||
}
|
||||
}
|
||||
return tickets;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not list tickets", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean updateStatus(long id, String status, String handledBy, long handledAt) {
|
||||
if (id <= 0 || status == null || status.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String sql = "UPDATE player_tickets SET status = ?, updated_at = ?, handled_by = ?, handled_at = ? WHERE id = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, status.trim().toUpperCase(Locale.ROOT));
|
||||
statement.setLong(2, handledAt);
|
||||
statement.setString(3, handledBy == null ? "" : handledBy);
|
||||
statement.setLong(4, handledAt);
|
||||
statement.setLong(5, id);
|
||||
return statement.executeUpdate() > 0;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not update ticket status", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private PlayerTicket read(ResultSet resultSet) throws SQLException {
|
||||
return new PlayerTicket(
|
||||
resultSet.getLong("id"),
|
||||
UUID.fromString(resultSet.getString("creator_uuid")),
|
||||
resultSet.getString("creator_name"),
|
||||
resultSet.getString("category"),
|
||||
resultSet.getString("description"),
|
||||
resultSet.getString("status"),
|
||||
resultSet.getLong("created_at"),
|
||||
resultSet.getLong("updated_at"),
|
||||
resultSet.getString("handled_by"),
|
||||
resultSet.getLong("handled_at")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package de.winniepat.minePanel.extensions.tickets;
|
||||
|
||||
import de.winniepat.minePanel.extensions.ExtensionContext;
|
||||
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
|
||||
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
|
||||
import de.winniepat.minePanel.extensions.MinePanelExtension;
|
||||
import de.winniepat.minePanel.users.PanelPermission;
|
||||
import org.bukkit.event.HandlerList;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
public final class TicketSystemExtension implements MinePanelExtension {
|
||||
|
||||
private ExtensionContext context;
|
||||
private TicketRepository ticketRepository;
|
||||
private TicketMenuListener menuListener;
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "ticket-system";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "Ticket System";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad(ExtensionContext context) {
|
||||
this.context = context;
|
||||
this.ticketRepository = new TicketRepository(context.database());
|
||||
this.ticketRepository.initializeSchema();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
this.menuListener = new TicketMenuListener(ticketRepository, context.knownPlayerRepository(), context.panelLogger());
|
||||
context.plugin().getServer().getPluginManager().registerEvents(menuListener, context.plugin());
|
||||
|
||||
boolean registered = context.commandRegistry().register(
|
||||
id(),
|
||||
"ticket",
|
||||
"Create a support ticket for server staff",
|
||||
"/ticket",
|
||||
"minepanel.ticket",
|
||||
List.of("support"),
|
||||
new TicketCommand(menuListener)
|
||||
);
|
||||
|
||||
if (!registered) {
|
||||
context.plugin().getLogger().warning("Could not register /ticket command for ticket extension.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (menuListener != null) {
|
||||
HandlerList.unregisterAll(menuListener);
|
||||
menuListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
|
||||
webRegistry.get("/api/extensions/tickets", PanelPermission.VIEW_TICKETS, (request, response, user) -> {
|
||||
String status = request.queryParams("status");
|
||||
List<Map<String, Object>> tickets = ticketRepository.listTickets(status).stream().map(this::toPayload).toList();
|
||||
return webRegistry.json(response, 200, Map.of("tickets", tickets));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/tickets/:id/close", PanelPermission.MANAGE_TICKETS, (request, response, user) -> {
|
||||
long ticketId = parseTicketId(request.params("id"));
|
||||
if (ticketId <= 0) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_ticket_id"));
|
||||
}
|
||||
|
||||
boolean updated = ticketRepository.updateStatus(ticketId, "CLOSED", user.username(), Instant.now().toEpochMilli());
|
||||
if (!updated) {
|
||||
return webRegistry.json(response, 404, Map.of("error", "ticket_not_found"));
|
||||
}
|
||||
|
||||
context.panelLogger().log("AUDIT", user.username(), "Closed ticket #" + ticketId);
|
||||
return webRegistry.json(response, 200, Map.of("ok", true));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/tickets/:id/reopen", PanelPermission.MANAGE_TICKETS, (request, response, user) -> {
|
||||
long ticketId = parseTicketId(request.params("id"));
|
||||
if (ticketId <= 0) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_ticket_id"));
|
||||
}
|
||||
|
||||
boolean updated = ticketRepository.updateStatus(ticketId, "OPEN", user.username(), Instant.now().toEpochMilli());
|
||||
if (!updated) {
|
||||
return webRegistry.json(response, 404, Map.of("error", "ticket_not_found"));
|
||||
}
|
||||
|
||||
context.panelLogger().log("AUDIT", user.username(), "Reopened ticket #" + ticketId);
|
||||
return webRegistry.json(response, 200, Map.of("ok", true));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ExtensionNavigationTab> navigationTabs() {
|
||||
return List.of(new ExtensionNavigationTab("server", "Tickets", "/dashboard/tickets"));
|
||||
}
|
||||
|
||||
private long parseTicketId(String rawId) {
|
||||
try {
|
||||
return Long.parseLong(rawId);
|
||||
} catch (Exception ignored) {
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> toPayload(PlayerTicket ticket) {
|
||||
return Map.of(
|
||||
"id", ticket.id(),
|
||||
"creatorUuid", ticket.creatorUuid().toString(),
|
||||
"creatorName", ticket.creatorName(),
|
||||
"category", ticket.category(),
|
||||
"description", ticket.description(),
|
||||
"status", ticket.status(),
|
||||
"createdAt", ticket.createdAt(),
|
||||
"updatedAt", ticket.updatedAt(),
|
||||
"handledBy", ticket.handledBy(),
|
||||
"handledAt", ticket.handledAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package de.winniepat.minePanel.extensions.whitelist;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import de.winniepat.minePanel.extensions.ExtensionContext;
|
||||
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
|
||||
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
|
||||
import de.winniepat.minePanel.extensions.MinePanelExtension;
|
||||
import de.winniepat.minePanel.users.PanelPermission;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class WhitelistExtension implements MinePanelExtension {
|
||||
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
private ExtensionContext context;
|
||||
private WhitelistService whitelistService;
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "whitelist";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "Whitelist";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad(ExtensionContext context) {
|
||||
this.context = context;
|
||||
this.whitelistService = new WhitelistService(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
|
||||
webRegistry.get("/api/extensions/whitelist/status", PanelPermission.VIEW_WHITELIST, (request, response, user) -> {
|
||||
try {
|
||||
boolean enabled = whitelistService.isWhitelistEnabled();
|
||||
return webRegistry.json(response, 200, Map.of("enabled", enabled));
|
||||
} catch (IllegalStateException exception) {
|
||||
return webRegistry.json(response, 500, Map.of("error", "whitelist_status_failed", "details", exception.getMessage()));
|
||||
}
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/whitelist/toggle", PanelPermission.MANAGE_WHITELIST, (request, response, user) -> {
|
||||
TogglePayload payload = gson.fromJson(request.body(), TogglePayload.class);
|
||||
if (payload == null || payload.enabled() == null) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
|
||||
}
|
||||
|
||||
boolean changed;
|
||||
try {
|
||||
changed = whitelistService.setWhitelistEnabled(payload.enabled());
|
||||
} catch (IllegalStateException exception) {
|
||||
return webRegistry.json(response, 500, Map.of("error", "whitelist_toggle_failed", "details", exception.getMessage()));
|
||||
}
|
||||
|
||||
context.panelLogger().log(
|
||||
"AUDIT",
|
||||
user.username(),
|
||||
(payload.enabled() ? "Enabled" : "Disabled") + " server whitelist" + (changed ? "" : " (no change)")
|
||||
);
|
||||
return webRegistry.json(response, 200, Map.of("ok", true, "enabled", payload.enabled(), "changed", changed));
|
||||
});
|
||||
|
||||
webRegistry.get("/api/extensions/whitelist", PanelPermission.VIEW_WHITELIST, (request, response, user) -> {
|
||||
try {
|
||||
List<Map<String, Object>> entries = whitelistService.listEntries();
|
||||
return webRegistry.json(response, 200, Map.of("entries", entries, "count", entries.size()));
|
||||
} catch (IllegalStateException exception) {
|
||||
return webRegistry.json(response, 500, Map.of("error", "whitelist_list_failed", "details", exception.getMessage()));
|
||||
}
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/whitelist/add", PanelPermission.MANAGE_WHITELIST, (request, response, user) -> {
|
||||
UsernamePayload payload = gson.fromJson(request.body(), UsernamePayload.class);
|
||||
String username = payload == null ? null : payload.username();
|
||||
|
||||
WhitelistService.ChangeResult result;
|
||||
try {
|
||||
result = whitelistService.addByUsername(username);
|
||||
} catch (IllegalStateException exception) {
|
||||
return webRegistry.json(response, 500, Map.of("error", "whitelist_add_failed", "details", exception.getMessage()));
|
||||
}
|
||||
|
||||
if (!result.success()) {
|
||||
return webRegistry.json(response, 400, Map.of("error", result.error()));
|
||||
}
|
||||
|
||||
context.panelLogger().log("AUDIT", user.username(),
|
||||
(result.changed() ? "Added " : "Kept ") + result.username() + " on whitelist");
|
||||
return webRegistry.json(response, 200, Map.of(
|
||||
"ok", true,
|
||||
"changed", result.changed(),
|
||||
"uuid", result.uuid() == null ? "" : result.uuid().toString(),
|
||||
"username", result.username()
|
||||
));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/whitelist/remove", PanelPermission.MANAGE_WHITELIST, (request, response, user) -> {
|
||||
UsernamePayload payload = gson.fromJson(request.body(), UsernamePayload.class);
|
||||
String username = payload == null ? null : payload.username();
|
||||
|
||||
WhitelistService.ChangeResult result;
|
||||
try {
|
||||
result = whitelistService.removeByUsername(username);
|
||||
} catch (IllegalStateException exception) {
|
||||
return webRegistry.json(response, 500, Map.of("error", "whitelist_remove_failed", "details", exception.getMessage()));
|
||||
}
|
||||
|
||||
if (!result.success()) {
|
||||
int status = "not_whitelisted".equals(result.error()) ? 404 : 400;
|
||||
return webRegistry.json(response, status, Map.of("error", result.error()));
|
||||
}
|
||||
|
||||
context.panelLogger().log("AUDIT", user.username(), "Removed " + result.username() + " from whitelist");
|
||||
return webRegistry.json(response, 200, Map.of(
|
||||
"ok", true,
|
||||
"changed", result.changed(),
|
||||
"uuid", result.uuid() == null ? "" : result.uuid().toString(),
|
||||
"username", result.username()
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ExtensionNavigationTab> navigationTabs() {
|
||||
return List.of(new ExtensionNavigationTab("server", "Whitelist", "/dashboard/whitelist"));
|
||||
}
|
||||
|
||||
private record UsernamePayload(String username) {
|
||||
}
|
||||
|
||||
private record TogglePayload(Boolean enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package de.winniepat.minePanel.extensions.whitelist;
|
||||
|
||||
import de.winniepat.minePanel.extensions.ExtensionContext;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
public final class WhitelistService {
|
||||
|
||||
private final ExtensionContext context;
|
||||
|
||||
public WhitelistService(ExtensionContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listEntries() {
|
||||
return runSync(() -> {
|
||||
List<Map<String, Object>> entries = new ArrayList<>();
|
||||
for (OfflinePlayer player : context.plugin().getServer().getWhitelistedPlayers()) {
|
||||
String name = player.getName();
|
||||
String username = (name == null || name.isBlank()) ? player.getUniqueId().toString() : name;
|
||||
entries.add(Map.of(
|
||||
"uuid", player.getUniqueId().toString(),
|
||||
"username", username,
|
||||
"online", player.isOnline()
|
||||
));
|
||||
}
|
||||
|
||||
entries.sort(Comparator.comparing(entry -> String.valueOf(entry.get("username")), String.CASE_INSENSITIVE_ORDER));
|
||||
return entries;
|
||||
});
|
||||
}
|
||||
|
||||
public boolean isWhitelistEnabled() {
|
||||
return runSync(() -> context.plugin().getServer().hasWhitelist());
|
||||
}
|
||||
|
||||
public boolean setWhitelistEnabled(boolean enabled) {
|
||||
return runSync(() -> {
|
||||
boolean before = context.plugin().getServer().hasWhitelist();
|
||||
if (before == enabled) {
|
||||
return false;
|
||||
}
|
||||
context.plugin().getServer().setWhitelist(enabled);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public ChangeResult addByUsername(String rawUsername) {
|
||||
String username = normalizeUsername(rawUsername);
|
||||
if (username.isBlank()) {
|
||||
return ChangeResult.invalid("invalid_username");
|
||||
}
|
||||
|
||||
return runSync(() -> {
|
||||
OfflinePlayer target = resolvePlayerByUsername(username);
|
||||
if (target == null) {
|
||||
return ChangeResult.invalid("player_not_found");
|
||||
}
|
||||
|
||||
if (target.isWhitelisted()) {
|
||||
return ChangeResult.ok(target, false);
|
||||
}
|
||||
|
||||
target.setWhitelisted(true);
|
||||
return ChangeResult.ok(target, true);
|
||||
});
|
||||
}
|
||||
|
||||
public ChangeResult removeByUsername(String rawUsername) {
|
||||
String username = normalizeUsername(rawUsername);
|
||||
if (username.isBlank()) {
|
||||
return ChangeResult.invalid("invalid_username");
|
||||
}
|
||||
|
||||
return runSync(() -> {
|
||||
OfflinePlayer target = findWhitelistedByUsername(username);
|
||||
if (target == null) {
|
||||
return ChangeResult.invalid("not_whitelisted");
|
||||
}
|
||||
|
||||
target.setWhitelisted(false);
|
||||
if (target.isOnline() && target.getPlayer() != null) {
|
||||
target.getPlayer().kickPlayer("You have been removed from the whitelist.");
|
||||
}
|
||||
return ChangeResult.ok(target, true);
|
||||
});
|
||||
}
|
||||
|
||||
private OfflinePlayer resolvePlayerByUsername(String username) {
|
||||
OfflinePlayer online = context.plugin().getServer().getPlayerExact(username);
|
||||
if (online != null) {
|
||||
return online;
|
||||
}
|
||||
|
||||
for (OfflinePlayer offline : context.plugin().getServer().getOfflinePlayers()) {
|
||||
if (offline.getName() != null && offline.getName().equalsIgnoreCase(username)) {
|
||||
return offline;
|
||||
}
|
||||
}
|
||||
|
||||
OfflinePlayer fallback = context.plugin().getServer().getOfflinePlayer(username);
|
||||
return (fallback.getName() == null && !fallback.hasPlayedBefore()) ? null : fallback;
|
||||
}
|
||||
|
||||
private OfflinePlayer findWhitelistedByUsername(String username) {
|
||||
for (OfflinePlayer player : context.plugin().getServer().getWhitelistedPlayers()) {
|
||||
String name = player.getName();
|
||||
if (name != null && name.equalsIgnoreCase(username)) {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String normalizeUsername(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String username = value.trim();
|
||||
if (username.length() < 3 || username.length() > 16) {
|
||||
return "";
|
||||
}
|
||||
if (!username.matches("^[A-Za-z0-9_]+$")) {
|
||||
return "";
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
private <T> T runSync(Callable<T> task) {
|
||||
try {
|
||||
return context.schedulerBridge().callGlobal(task, 10, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("whitelist_task_interrupted", exception);
|
||||
} catch (ExecutionException | TimeoutException exception) {
|
||||
throw new IllegalStateException("whitelist_task_failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public record ChangeResult(boolean success, boolean changed, String error, UUID uuid, String username) {
|
||||
private static ChangeResult ok(OfflinePlayer player, boolean changed) {
|
||||
String username = player.getName() == null || player.getName().isBlank()
|
||||
? player.getUniqueId().toString()
|
||||
: player.getName();
|
||||
return new ChangeResult(true, changed, "", player.getUniqueId(), username);
|
||||
}
|
||||
|
||||
private static ChangeResult invalid(String error) {
|
||||
return new ChangeResult(false, false, error, null, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
package de.winniepat.minePanel.extensions.worldbackups;
|
||||
|
||||
import de.winniepat.minePanel.MinePanel;
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
public final class WorldBackupService {
|
||||
|
||||
private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss").withZone(ZoneOffset.UTC);
|
||||
private static final Set<String> WORLD_KEYS = Set.of("overworld", "nether", "end");
|
||||
|
||||
private final MinePanel plugin;
|
||||
private final Path backupsRoot;
|
||||
|
||||
public WorldBackupService(MinePanel plugin, Path backupsRoot) {
|
||||
this.plugin = plugin;
|
||||
this.backupsRoot = backupsRoot;
|
||||
}
|
||||
|
||||
public List<String> supportedWorldKeys() {
|
||||
return List.of("overworld", "nether", "end");
|
||||
}
|
||||
|
||||
public BackupCreateResult createBackup(String worldKey, String requestedName) {
|
||||
String normalizedWorldKey = normalizeWorldKey(worldKey);
|
||||
if (normalizedWorldKey == null) {
|
||||
return BackupCreateResult.error("invalid_world");
|
||||
}
|
||||
|
||||
String safeName = sanitizeBackupName(requestedName);
|
||||
if (safeName.isBlank()) {
|
||||
return BackupCreateResult.error("invalid_name");
|
||||
}
|
||||
|
||||
WorldSnapshot snapshot = captureWorldSnapshot(normalizedWorldKey);
|
||||
if (snapshot == null) {
|
||||
return BackupCreateResult.error("world_not_found");
|
||||
}
|
||||
|
||||
Path worldBackupDir = backupsRoot.resolve(normalizedWorldKey);
|
||||
String fileName = FILE_TIMESTAMP.format(Instant.now()) + "__" + safeName + ".zip";
|
||||
Path tempZip = null;
|
||||
try {
|
||||
Files.createDirectories(backupsRoot);
|
||||
Files.createDirectories(worldBackupDir);
|
||||
Path zipFile = resolveUniqueZipPath(worldBackupDir, fileName);
|
||||
tempZip = resolveUniqueTempPath(worldBackupDir);
|
||||
zipDirectory(snapshot.worldFolder(), tempZip);
|
||||
|
||||
try {
|
||||
Files.move(tempZip, zipFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException ignored) {
|
||||
Files.move(tempZip, zipFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
long sizeBytes = Files.size(zipFile);
|
||||
long createdAt = Files.getLastModifiedTime(zipFile).toMillis();
|
||||
return BackupCreateResult.success(new WorldBackupEntry(zipFile.getFileName().toString(), safeName, createdAt, sizeBytes, normalizedWorldKey));
|
||||
} catch (Exception exception) {
|
||||
return BackupCreateResult.error("backup_failed", buildErrorDetails(exception));
|
||||
} finally {
|
||||
if (tempZip != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempZip);
|
||||
} catch (IOException ignored) {
|
||||
// Best effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveUniqueZipPath(Path worldBackupDir, String baseFileName) throws IOException {
|
||||
Path initial = worldBackupDir.resolve(baseFileName).normalize();
|
||||
if (!Files.exists(initial)) {
|
||||
return initial;
|
||||
}
|
||||
|
||||
String base = baseFileName;
|
||||
String suffix = ".zip";
|
||||
if (baseFileName.toLowerCase(Locale.ROOT).endsWith(suffix)) {
|
||||
base = baseFileName.substring(0, baseFileName.length() - suffix.length());
|
||||
}
|
||||
|
||||
for (int i = 2; i <= 9999; i++) {
|
||||
Path candidate = worldBackupDir.resolve(base + "-" + i + suffix).normalize();
|
||||
if (!Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("could_not_allocate_unique_backup_file_name");
|
||||
}
|
||||
|
||||
private Path resolveUniqueTempPath(Path worldBackupDir) throws IOException {
|
||||
for (int i = 0; i < 10_000; i++) {
|
||||
String candidateName = "backup-" + UUID.randomUUID() + ".tmp";
|
||||
Path candidate = worldBackupDir.resolve(candidateName).normalize();
|
||||
if (!Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("could_not_allocate_unique_temp_file_name");
|
||||
}
|
||||
|
||||
private String buildErrorDetails(Exception exception) {
|
||||
String message = exception.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
return exception.getClass().getSimpleName();
|
||||
}
|
||||
return exception.getClass().getSimpleName() + ": " + message;
|
||||
}
|
||||
|
||||
public List<WorldBackupEntry> listBackups(String worldKey) {
|
||||
String normalizedWorldKey = normalizeWorldKey(worldKey);
|
||||
if (normalizedWorldKey == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
Path worldBackupDir = backupsRoot.resolve(normalizedWorldKey);
|
||||
if (!Files.isDirectory(worldBackupDir)) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
try (var files = Files.list(worldBackupDir)) {
|
||||
return files
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip"))
|
||||
.map(path -> toEntry(path, normalizedWorldKey))
|
||||
.filter(Objects::nonNull)
|
||||
.sorted(Comparator.comparingLong(WorldBackupEntry::createdAt).reversed())
|
||||
.toList();
|
||||
} catch (IOException exception) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public BackupDeleteResult deleteBackup(String worldKey, String fileName) {
|
||||
String normalizedWorldKey = normalizeWorldKey(worldKey);
|
||||
if (normalizedWorldKey == null) {
|
||||
return BackupDeleteResult.error("invalid_world");
|
||||
}
|
||||
|
||||
if (fileName == null || fileName.isBlank()) {
|
||||
return BackupDeleteResult.error("invalid_file_name");
|
||||
}
|
||||
|
||||
String trimmedFileName = fileName.trim();
|
||||
if (!trimmedFileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
|
||||
return BackupDeleteResult.error("invalid_file_name");
|
||||
}
|
||||
if (trimmedFileName.contains("/") || trimmedFileName.contains("\\") || trimmedFileName.contains("..")) {
|
||||
return BackupDeleteResult.error("invalid_file_name");
|
||||
}
|
||||
|
||||
Path worldBackupDir = backupsRoot.resolve(normalizedWorldKey).normalize();
|
||||
Path target = worldBackupDir.resolve(trimmedFileName).normalize();
|
||||
if (!target.startsWith(worldBackupDir)) {
|
||||
return BackupDeleteResult.error("invalid_file_name");
|
||||
}
|
||||
|
||||
if (!Files.exists(target) || !Files.isRegularFile(target)) {
|
||||
return BackupDeleteResult.error("backup_not_found");
|
||||
}
|
||||
|
||||
try {
|
||||
Files.delete(target);
|
||||
return BackupDeleteResult.success(trimmedFileName, normalizedWorldKey);
|
||||
} catch (Exception exception) {
|
||||
return BackupDeleteResult.error("delete_failed", buildErrorDetails(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private WorldSnapshot captureWorldSnapshot(String worldKey) {
|
||||
try {
|
||||
return plugin.schedulerBridge().callGlobal(() -> {
|
||||
World world = resolveWorld(worldKey);
|
||||
if (world == null) {
|
||||
return null;
|
||||
}
|
||||
world.save();
|
||||
return new WorldSnapshot(world.getName(), world.getWorldFolder().toPath());
|
||||
}, 3, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} catch (ExecutionException | TimeoutException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private World resolveWorld(String worldKey) {
|
||||
String baseLevelName = plugin.getServer().getWorlds().isEmpty()
|
||||
? "world"
|
||||
: plugin.getServer().getWorlds().get(0).getName();
|
||||
|
||||
return switch (worldKey) {
|
||||
case "overworld" -> firstWorldByNames(List.of(baseLevelName, "world"), World.Environment.NORMAL);
|
||||
case "nether" -> firstWorldByNames(List.of(baseLevelName + "_nether", "world_nether"), World.Environment.NETHER);
|
||||
case "end" -> firstWorldByNames(List.of(baseLevelName + "_the_end", "world_the_end", baseLevelName + "_end"), World.Environment.THE_END);
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private World firstWorldByNames(List<String> candidates, World.Environment fallbackEnvironment) {
|
||||
for (String candidate : candidates) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
World world = plugin.getServer().getWorld(candidate);
|
||||
if (world != null) {
|
||||
return world;
|
||||
}
|
||||
}
|
||||
|
||||
return plugin.getServer().getWorlds().stream()
|
||||
.filter(world -> world.getEnvironment() == fallbackEnvironment)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private WorldBackupEntry toEntry(Path file, String worldKey) {
|
||||
try {
|
||||
String fileName = file.getFileName().toString();
|
||||
String displayName = parseNameFromFileName(fileName);
|
||||
long createdAt = Files.getLastModifiedTime(file).toMillis();
|
||||
long sizeBytes = Files.size(file);
|
||||
return new WorldBackupEntry(fileName, displayName, createdAt, sizeBytes, worldKey);
|
||||
} catch (IOException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String parseNameFromFileName(String fileName) {
|
||||
int separatorIndex = fileName.indexOf("__");
|
||||
if (separatorIndex < 0) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
String withoutPrefix = fileName.substring(separatorIndex + 2);
|
||||
if (withoutPrefix.toLowerCase(Locale.ROOT).endsWith(".zip")) {
|
||||
return withoutPrefix.substring(0, withoutPrefix.length() - 4);
|
||||
}
|
||||
return withoutPrefix;
|
||||
}
|
||||
|
||||
private void zipDirectory(Path sourceDirectory, Path zipFile) throws IOException {
|
||||
try (ZipOutputStream zipStream = new ZipOutputStream(Files.newOutputStream(zipFile, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE))) {
|
||||
Files.walkFileTree(sourceDirectory, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attrs) throws IOException {
|
||||
Path relative = sourceDirectory.relativize(directory);
|
||||
if (!relative.toString().isEmpty()) {
|
||||
String entryName = normalizeEntryName(relative) + "/";
|
||||
try {
|
||||
zipStream.putNextEntry(new ZipEntry(entryName));
|
||||
zipStream.closeEntry();
|
||||
} catch (IOException ignored) {
|
||||
// Ignore duplicate/invalid directory entries and continue.
|
||||
}
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
|
||||
if (Files.isSymbolicLink(file)) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
Path relative = sourceDirectory.relativize(file);
|
||||
if (shouldSkipFile(relative)) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
if (!Files.isReadable(file)) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
String entryName = normalizeEntryName(relative);
|
||||
try {
|
||||
zipStream.putNextEntry(new ZipEntry(entryName));
|
||||
Files.copy(file, zipStream);
|
||||
zipStream.closeEntry();
|
||||
} catch (IOException ignored) {
|
||||
// Skip files that are temporarily locked by the OS or server process.
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path file, IOException exc) {
|
||||
// Continue if a file cannot be read due to locks/permissions.
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
} catch (UncheckedIOException exception) {
|
||||
throw exception.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldSkipFile(Path relativePath) {
|
||||
String name = relativePath.getFileName() == null ? "" : relativePath.getFileName().toString().toLowerCase(Locale.ROOT);
|
||||
return "session.lock".equals(name);
|
||||
}
|
||||
|
||||
private String normalizeEntryName(Path relativePath) {
|
||||
return relativePath.toString().replace('\\', '/');
|
||||
}
|
||||
|
||||
private String sanitizeBackupName(String rawName) {
|
||||
if (rawName == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder sanitized = new StringBuilder();
|
||||
String trimmed = rawName.trim();
|
||||
for (int i = 0; i < trimmed.length(); i++) {
|
||||
char c = trimmed.charAt(i);
|
||||
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
sanitized.append(c);
|
||||
} else if (c == ' ' || c == '.') {
|
||||
sanitized.append('_');
|
||||
}
|
||||
}
|
||||
|
||||
String value = sanitized.toString().replaceAll("_+", "_");
|
||||
if (value.length() > 48) {
|
||||
return value.substring(0, 48);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String normalizeWorldKey(String worldKey) {
|
||||
if (worldKey == null) {
|
||||
return null;
|
||||
}
|
||||
String normalized = worldKey.trim().toLowerCase(Locale.ROOT);
|
||||
return WORLD_KEYS.contains(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
public record WorldBackupEntry(String fileName, String name, long createdAt, long sizeBytes, String world) {
|
||||
}
|
||||
|
||||
public record BackupCreateResult(boolean success, String error, String details, WorldBackupEntry backup) {
|
||||
static BackupCreateResult success(WorldBackupEntry backup) {
|
||||
return new BackupCreateResult(true, "", "", backup);
|
||||
}
|
||||
|
||||
static BackupCreateResult error(String error) {
|
||||
return new BackupCreateResult(false, error, "", null);
|
||||
}
|
||||
|
||||
static BackupCreateResult error(String error, String details) {
|
||||
return new BackupCreateResult(false, error, details == null ? "" : details, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record BackupDeleteResult(boolean success, String error, String details, String fileName, String world) {
|
||||
static BackupDeleteResult success(String fileName, String world) {
|
||||
return new BackupDeleteResult(true, "", "", fileName, world);
|
||||
}
|
||||
|
||||
static BackupDeleteResult error(String error) {
|
||||
return new BackupDeleteResult(false, error, "", "", "");
|
||||
}
|
||||
|
||||
static BackupDeleteResult error(String error, String details) {
|
||||
return new BackupDeleteResult(false, error, details == null ? "" : details, "", "");
|
||||
}
|
||||
}
|
||||
|
||||
private record WorldSnapshot(String worldName, Path worldFolder) {
|
||||
}
|
||||
}
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package de.winniepat.minePanel.extensions.worldbackups;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import de.winniepat.minePanel.extensions.ExtensionContext;
|
||||
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
|
||||
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
|
||||
import de.winniepat.minePanel.extensions.MinePanelExtension;
|
||||
import de.winniepat.minePanel.users.PanelPermission;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
public final class WorldBackupsExtension implements MinePanelExtension {
|
||||
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
private ExtensionContext context;
|
||||
private WorldBackupService backupService;
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "world-backups";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "World Backups";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad(ExtensionContext context) {
|
||||
this.context = context;
|
||||
Path backupRoot = context.plugin().getDataFolder().toPath().resolve("backups");
|
||||
this.backupService = new WorldBackupService(context.plugin(), backupRoot);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
|
||||
webRegistry.get("/api/extensions/world-backups", PanelPermission.VIEW_BACKUPS, (request, response, user) -> {
|
||||
String world = request.queryParams("world");
|
||||
if (world != null && !world.isBlank()) {
|
||||
List<Map<String, Object>> backups = backupService.listBackups(world).stream().map(this::toPayload).toList();
|
||||
return webRegistry.json(response, 200, Map.of("world", world, "backups", backups));
|
||||
}
|
||||
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
for (String worldKey : backupService.supportedWorldKeys()) {
|
||||
List<Map<String, Object>> backups = backupService.listBackups(worldKey).stream().map(this::toPayload).toList();
|
||||
payload.put(worldKey, backups);
|
||||
}
|
||||
|
||||
payload.put("worlds", backupService.supportedWorldKeys());
|
||||
return webRegistry.json(response, 200, payload);
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/world-backups/create", PanelPermission.MANAGE_BACKUPS, (request, response, user) -> {
|
||||
CreateBackupPayload payload = gson.fromJson(request.body(), CreateBackupPayload.class);
|
||||
if (payload == null || isBlank(payload.world()) || isBlank(payload.name())) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
|
||||
}
|
||||
|
||||
WorldBackupService.BackupCreateResult result = backupService.createBackup(payload.world(), payload.name());
|
||||
if (!result.success()) {
|
||||
Map<String, Object> errorPayload = new HashMap<>();
|
||||
errorPayload.put("error", result.error());
|
||||
if (!isBlank(result.details())) {
|
||||
errorPayload.put("details", result.details());
|
||||
}
|
||||
|
||||
context.plugin().getLogger().warning("World backup failed for world='" + payload.world()
|
||||
+ "', name='" + payload.name() + "': "
|
||||
+ (isBlank(result.details()) ? result.error() : result.details()));
|
||||
|
||||
int status;
|
||||
if ("invalid_world".equals(result.error()) || "invalid_name".equals(result.error())) {
|
||||
status = 400;
|
||||
} else if ("world_not_found".equals(result.error())) {
|
||||
status = 404;
|
||||
} else {
|
||||
status = 500;
|
||||
}
|
||||
return webRegistry.json(response, status, errorPayload);
|
||||
}
|
||||
|
||||
context.panelLogger().log("AUDIT", user.username(), "Created " + result.backup().world() + " backup: " + result.backup().fileName());
|
||||
return webRegistry.json(response, 200, Map.of("ok", true, "backup", toPayload(result.backup())));
|
||||
});
|
||||
|
||||
webRegistry.post("/api/extensions/world-backups/delete", PanelPermission.MANAGE_BACKUPS, (request, response, user) -> {
|
||||
DeleteBackupPayload payload = gson.fromJson(request.body(), DeleteBackupPayload.class);
|
||||
if (payload == null || isBlank(payload.world()) || isBlank(payload.fileName())) {
|
||||
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
|
||||
}
|
||||
|
||||
WorldBackupService.BackupDeleteResult result = backupService.deleteBackup(payload.world(), payload.fileName());
|
||||
if (!result.success()) {
|
||||
Map<String, Object> errorPayload = new HashMap<>();
|
||||
errorPayload.put("error", result.error());
|
||||
if (!isBlank(result.details())) {
|
||||
errorPayload.put("details", result.details());
|
||||
}
|
||||
|
||||
int status;
|
||||
if ("invalid_world".equals(result.error()) || "invalid_file_name".equals(result.error())) {
|
||||
status = 400;
|
||||
} else if ("backup_not_found".equals(result.error())) {
|
||||
status = 404;
|
||||
} else {
|
||||
status = 500;
|
||||
}
|
||||
return webRegistry.json(response, status, errorPayload);
|
||||
}
|
||||
|
||||
context.panelLogger().log("AUDIT", user.username(), "Deleted " + result.world() + " backup: " + result.fileName());
|
||||
return webRegistry.json(response, 200, Map.of("ok", true, "fileName", result.fileName(), "world", result.world()));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ExtensionNavigationTab> navigationTabs() {
|
||||
return List.of(new ExtensionNavigationTab("server", "Backups", "/dashboard/world-backups"));
|
||||
}
|
||||
|
||||
private Map<String, Object> toPayload(WorldBackupService.WorldBackupEntry backup) {
|
||||
return Map.of(
|
||||
"fileName", backup.fileName(),
|
||||
"name", backup.name(),
|
||||
"createdAt", backup.createdAt(),
|
||||
"sizeBytes", backup.sizeBytes(),
|
||||
"world", backup.world()
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private record CreateBackupPayload(String world, String name) {
|
||||
}
|
||||
|
||||
private record DeleteBackupPayload(String world, String fileName) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.winniepat.minePanel.integrations;
|
||||
|
||||
public record DiscordWebhookConfig(
|
||||
boolean enabled,
|
||||
String webhookUrl,
|
||||
boolean useEmbed,
|
||||
String botName,
|
||||
String messageTemplate,
|
||||
String embedTitleTemplate,
|
||||
boolean logChat,
|
||||
boolean logCommands,
|
||||
boolean logAuth,
|
||||
boolean logAudit,
|
||||
boolean logSecurity,
|
||||
boolean logConsoleResponse,
|
||||
boolean logSystem
|
||||
) {
|
||||
|
||||
public static DiscordWebhookConfig defaults() {
|
||||
return new DiscordWebhookConfig(
|
||||
false,
|
||||
"",
|
||||
true,
|
||||
"MinePanel",
|
||||
"[{timestamp}] [{kind}] [{source}] {message}",
|
||||
"MinePanel {kind}",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package de.winniepat.minePanel.integrations;
|
||||
|
||||
import de.winniepat.minePanel.persistence.Database;
|
||||
|
||||
import java.sql.*;
|
||||
|
||||
public final class DiscordWebhookRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public DiscordWebhookRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public DiscordWebhookConfig load() {
|
||||
String sql = "SELECT enabled, webhook_url, use_embed, bot_name, message_template, embed_title_template, "
|
||||
+ "log_chat, log_commands, log_auth, log_audit, log_security, log_console_response, log_system "
|
||||
+ "FROM discord_webhook_config WHERE id = 1";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return new DiscordWebhookConfig(
|
||||
resultSet.getInt("enabled") == 1,
|
||||
resultSet.getString("webhook_url"),
|
||||
resultSet.getInt("use_embed") == 1,
|
||||
resultSet.getString("bot_name"),
|
||||
resultSet.getString("message_template"),
|
||||
resultSet.getString("embed_title_template"),
|
||||
resultSet.getInt("log_chat") == 1,
|
||||
resultSet.getInt("log_commands") == 1,
|
||||
resultSet.getInt("log_auth") == 1,
|
||||
resultSet.getInt("log_audit") == 1,
|
||||
resultSet.getInt("log_security") == 1,
|
||||
resultSet.getInt("log_console_response") == 1,
|
||||
resultSet.getInt("log_system") == 1
|
||||
);
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not load Discord webhook config", exception);
|
||||
}
|
||||
|
||||
DiscordWebhookConfig defaults = DiscordWebhookConfig.defaults();
|
||||
save(defaults);
|
||||
return defaults;
|
||||
}
|
||||
|
||||
public void save(DiscordWebhookConfig config) {
|
||||
String sql = "INSERT INTO discord_webhook_config("
|
||||
+ "id, enabled, webhook_url, use_embed, bot_name, message_template, embed_title_template, "
|
||||
+ "log_chat, log_commands, log_auth, log_audit, log_security, log_console_response, log_system"
|
||||
+ ") VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
+ "ON CONFLICT(id) DO UPDATE SET "
|
||||
+ "enabled=excluded.enabled, "
|
||||
+ "webhook_url=excluded.webhook_url, "
|
||||
+ "use_embed=excluded.use_embed, "
|
||||
+ "bot_name=excluded.bot_name, "
|
||||
+ "message_template=excluded.message_template, "
|
||||
+ "embed_title_template=excluded.embed_title_template, "
|
||||
+ "log_chat=excluded.log_chat, "
|
||||
+ "log_commands=excluded.log_commands, "
|
||||
+ "log_auth=excluded.log_auth, "
|
||||
+ "log_audit=excluded.log_audit, "
|
||||
+ "log_security=excluded.log_security, "
|
||||
+ "log_console_response=excluded.log_console_response, "
|
||||
+ "log_system=excluded.log_system";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, config.enabled() ? 1 : 0);
|
||||
statement.setString(2, nullSafe(config.webhookUrl()));
|
||||
statement.setInt(3, config.useEmbed() ? 1 : 0);
|
||||
statement.setString(4, nullSafe(config.botName()));
|
||||
statement.setString(5, nullSafe(config.messageTemplate()));
|
||||
statement.setString(6, nullSafe(config.embedTitleTemplate()));
|
||||
statement.setInt(7, config.logChat() ? 1 : 0);
|
||||
statement.setInt(8, config.logCommands() ? 1 : 0);
|
||||
statement.setInt(9, config.logAuth() ? 1 : 0);
|
||||
statement.setInt(10, config.logAudit() ? 1 : 0);
|
||||
statement.setInt(11, config.logSecurity() ? 1 : 0);
|
||||
statement.setInt(12, config.logConsoleResponse() ? 1 : 0);
|
||||
statement.setInt(13, config.logSystem() ? 1 : 0);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not save Discord webhook config", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String nullSafe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package de.winniepat.minePanel.integrations;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public final class DiscordWebhookService {
|
||||
|
||||
private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ISO_INSTANT;
|
||||
|
||||
private final Logger logger;
|
||||
private final DiscordWebhookRepository repository;
|
||||
private final AtomicReference<DiscordWebhookConfig> configReference;
|
||||
private final ExecutorService senderExecutor;
|
||||
private final HttpClient httpClient;
|
||||
private final Gson gson;
|
||||
|
||||
public DiscordWebhookService(Logger logger, DiscordWebhookRepository repository) {
|
||||
this.logger = logger;
|
||||
this.repository = repository;
|
||||
this.configReference = new AtomicReference<>(repository.load());
|
||||
this.senderExecutor = Executors.newSingleThreadExecutor(task -> {
|
||||
Thread thread = new Thread(task, "minepanel-discord-webhook");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
this.httpClient = HttpClient.newHttpClient();
|
||||
this.gson = new Gson();
|
||||
}
|
||||
|
||||
public DiscordWebhookConfig getConfig() {
|
||||
return configReference.get();
|
||||
}
|
||||
|
||||
public DiscordWebhookConfig updateConfig(DiscordWebhookConfig config) {
|
||||
repository.save(config);
|
||||
configReference.set(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
public void handlePanelLog(String kind, String source, String message, Instant timestamp) {
|
||||
DiscordWebhookConfig config = configReference.get();
|
||||
if (!config.enabled() || config.webhookUrl().isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldSend(config, kind)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String renderedMessage = renderTemplate(config.messageTemplate(), kind, source, message, timestamp);
|
||||
String botName = config.botName().isBlank() ? "MinePanel" : config.botName();
|
||||
|
||||
senderExecutor.execute(() -> send(config, botName, kind, renderedMessage, source, timestamp));
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
senderExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
private boolean shouldSend(DiscordWebhookConfig config, String kind) {
|
||||
return switch (kind) {
|
||||
case "CHAT" -> config.logChat();
|
||||
case "COMMAND", "CONSOLE_COMMAND" -> config.logCommands();
|
||||
case "AUTH" -> config.logAuth();
|
||||
case "AUDIT" -> config.logAudit();
|
||||
case "SECURITY" -> config.logSecurity();
|
||||
case "CONSOLE_RESPONSE" -> config.logConsoleResponse();
|
||||
case "SYSTEM" -> config.logSystem();
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private void send(DiscordWebhookConfig config, String botName, String kind, String renderedMessage, String source, Instant timestamp) {
|
||||
try {
|
||||
Map<String, Object> payload;
|
||||
if (config.useEmbed()) {
|
||||
String title = renderTemplate(config.embedTitleTemplate(), kind, source, renderedMessage, timestamp);
|
||||
payload = Map.of(
|
||||
"username", botName,
|
||||
"embeds", List.of(Map.of(
|
||||
"title", truncate(title, 256),
|
||||
"description", truncate(renderedMessage, 4096),
|
||||
"timestamp", timestamp.toString(),
|
||||
"color", 3447003
|
||||
))
|
||||
);
|
||||
} else {
|
||||
payload = Map.of(
|
||||
"username", botName,
|
||||
"content", truncate(renderedMessage, 2000)
|
||||
);
|
||||
}
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(config.webhookUrl()))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload), StandardCharsets.UTF_8))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() >= 300) {
|
||||
logger.warning("Discord webhook returned status " + response.statusCode());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
logger.warning("Discord webhook send failed: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String renderTemplate(String template, String kind, String source, String message, Instant timestamp) {
|
||||
String rawTemplate = (template == null || template.isBlank())
|
||||
? "[{timestamp}] [{kind}] [{source}] {message}"
|
||||
: template;
|
||||
|
||||
return rawTemplate
|
||||
.replace("{timestamp}", TIMESTAMP_FORMAT.format(timestamp))
|
||||
.replace("{kind}", kind == null ? "" : kind)
|
||||
.replace("{source}", source == null ? "" : source)
|
||||
.replace("{message}", message == null ? "" : message);
|
||||
}
|
||||
|
||||
private String truncate(String value, int maxLength) {
|
||||
if (value == null || value.length() <= maxLength) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
return value.substring(0, maxLength - 1) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package de.winniepat.minePanel.lifecycle;
|
||||
|
||||
import de.winniepat.minePanel.MinePanel;
|
||||
import de.winniepat.minePanel.logs.*;
|
||||
import de.winniepat.minePanel.persistence.*;
|
||||
import de.winniepat.minePanel.web.BootstrapService;
|
||||
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.logging.*;
|
||||
|
||||
public final class PluginLifecycleSupport {
|
||||
|
||||
private static final DateTimeFormatter EXPORT_FILE_NAME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
|
||||
|
||||
private PluginLifecycleSupport() {
|
||||
}
|
||||
|
||||
@SuppressWarnings("all")
|
||||
public static void configureThirdPartyStartupLogging() {
|
||||
System.setProperty("org.eclipse.jetty.util.log.announce", "false");
|
||||
System.setProperty("org.eclipse.jetty.LEVEL", "WARN");
|
||||
|
||||
Logger.getLogger("spark").setLevel(Level.WARNING);
|
||||
Logger.getLogger("org.eclipse.jetty").setLevel(Level.WARNING);
|
||||
Logger.getLogger("org.eclipse.jetty.util.log").setLevel(Level.WARNING);
|
||||
|
||||
try {
|
||||
Class<?> levelClass = Class.forName("org.apache.logging.log4j.Level");
|
||||
Class<?> configuratorClass = Class.forName("org.apache.logging.log4j.core.config.Configurator");
|
||||
Method setLevelMethod = configuratorClass.getMethod("setLevel", String.class, levelClass);
|
||||
Object warnLevel = levelClass.getField("WARN").get(null);
|
||||
|
||||
setLevelMethod.invoke(null, "spark", warnLevel);
|
||||
setLevelMethod.invoke(null, "org.eclipse.jetty", warnLevel);
|
||||
setLevelMethod.invoke(null, "org.eclipse.jetty.util.log", warnLevel);
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public static void announceMinePanelBanner(JavaPlugin plugin, ComponentLogger componentLogger, MiniMessage miniMessage) {
|
||||
plugin.getLogger().info("");
|
||||
componentLogger.info(miniMessage.deserialize("<gold> __ __ ____ </gold>"));
|
||||
componentLogger.info(miniMessage.deserialize("<gold>| \\/ | | _ \\ </gold>"));
|
||||
componentLogger.info("{}{}", miniMessage.deserialize("<gold>| |\\/| | | |_) | MinePanel: </gold>"), miniMessage.deserialize("<green>" + plugin.getDescription().getVersion() + "</green>"));
|
||||
componentLogger.info("{}{}", miniMessage.deserialize("<gold>| | | | | __/ Running on: </gold>"), miniMessage.deserialize("<aqua>" + plugin.getServer().getName() + "</aqua>" + "(" + plugin.getServer().getMinecraftVersion() + ")"));
|
||||
componentLogger.info(miniMessage.deserialize("<gold>|_| |_| |_| </gold>"));
|
||||
plugin.getLogger().info("");
|
||||
}
|
||||
|
||||
public static void announceBootstrapToken(BootstrapService bootstrapService, ComponentLogger componentLogger, MiniMessage miniMessage) {
|
||||
bootstrapService.getBootstrapToken().ifPresent(token -> {
|
||||
componentLogger.info("{}{}", miniMessage.deserialize("<dark_green>First launch setup token: </dark_green>"), token);
|
||||
componentLogger.info("");
|
||||
});
|
||||
}
|
||||
|
||||
public static void registerPluginListeners(
|
||||
MinePanel plugin,
|
||||
PanelLogger panelLogger,
|
||||
KnownPlayerRepository knownPlayerRepository,
|
||||
PlayerActivityRepository playerActivityRepository,
|
||||
JoinLeaveEventRepository joinLeaveEventRepository
|
||||
) {
|
||||
plugin.getServer().getPluginManager().registerEvents(new ChatCaptureListener(panelLogger), plugin);
|
||||
plugin.getServer().getPluginManager().registerEvents(new CommandCaptureListener(panelLogger), plugin);
|
||||
plugin.getServer().getPluginManager().registerEvents(new PlayerActivityListener(plugin, knownPlayerRepository, playerActivityRepository, joinLeaveEventRepository, panelLogger), plugin);
|
||||
}
|
||||
|
||||
public static void synchronizeKnownPlayers(Server server, KnownPlayerRepository knownPlayerRepository, PlayerActivityRepository playerActivityRepository) {
|
||||
for (OfflinePlayer offlinePlayer : server.getOfflinePlayers()) {
|
||||
if (offlinePlayer.getName() != null) {
|
||||
knownPlayerRepository.upsert(offlinePlayer.getUniqueId(), offlinePlayer.getName());
|
||||
playerActivityRepository.ensureFromOffline(
|
||||
offlinePlayer.getUniqueId(),
|
||||
Math.max(0L, offlinePlayer.getFirstPlayed()),
|
||||
Math.max(0L, offlinePlayer.getLastPlayed())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
server.getOnlinePlayers().forEach(player -> {
|
||||
knownPlayerRepository.upsert(player.getUniqueId(), player.getName());
|
||||
long now = Instant.now().toEpochMilli();
|
||||
playerActivityRepository.onJoin(
|
||||
player.getUniqueId(),
|
||||
now,
|
||||
player.getAddress() != null && player.getAddress().getAddress() != null
|
||||
? player.getAddress().getAddress().getHostAddress()
|
||||
: ""
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public static void exportPanelLogsToServerLogsDirectory(Path dataFolder, LogRepository logRepository, Logger logger) {
|
||||
try {
|
||||
Path logsDirectory = dataFolder.resolve("logs");
|
||||
Files.createDirectories(logsDirectory);
|
||||
|
||||
String timestamp = EXPORT_FILE_NAME_FORMAT.format(Instant.now().atZone(ZoneOffset.UTC));
|
||||
Path exportFile = logsDirectory.resolve("minepanel-panel-" + timestamp + ".log");
|
||||
|
||||
StringBuilder output = new StringBuilder();
|
||||
for (var entry : logRepository.allLogsAscending()) {
|
||||
output.append(Instant.ofEpochMilli(entry.createdAt()))
|
||||
.append(" [")
|
||||
.append(entry.kind())
|
||||
.append("] [")
|
||||
.append(entry.source())
|
||||
.append("] ")
|
||||
.append(entry.message())
|
||||
.append(System.lineSeparator());
|
||||
}
|
||||
|
||||
if (output.isEmpty()) {
|
||||
output.append(Instant.now()).append(" [SYSTEM] [PLUGIN] No panel logs available during shutdown export").append(System.lineSeparator());
|
||||
}
|
||||
|
||||
Files.writeString(exportFile, output, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
|
||||
|
||||
logger.info("Exported panel logs to " + exportFile.toAbsolutePath());
|
||||
} catch (IOException | IllegalStateException exception) {
|
||||
logger.warning("Could not export panel logs on disable: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.winniepat.minePanel.logs;
|
||||
|
||||
import io.papermc.paper.event.player.AsyncChatEvent;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import org.bukkit.event.*;
|
||||
|
||||
public final class ChatCaptureListener implements Listener {
|
||||
|
||||
private static final PlainTextComponentSerializer PLAIN_TEXT_SERIALIZER = PlainTextComponentSerializer.plainText();
|
||||
|
||||
private final PanelLogger panelLogger;
|
||||
|
||||
public ChatCaptureListener(PanelLogger panelLogger) {
|
||||
this.panelLogger = panelLogger;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onChat(AsyncChatEvent event) {
|
||||
String message = PLAIN_TEXT_SERIALIZER.serialize(event.message());
|
||||
panelLogger.log("CHAT", event.getPlayer().getName(), message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.winniepat.minePanel.logs;
|
||||
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
|
||||
import org.bukkit.event.server.ServerCommandEvent;
|
||||
|
||||
public final class CommandCaptureListener implements Listener {
|
||||
|
||||
private final PanelLogger panelLogger;
|
||||
|
||||
public CommandCaptureListener(PanelLogger panelLogger) {
|
||||
this.panelLogger = panelLogger;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onPlayerCommand(PlayerCommandPreprocessEvent event) {
|
||||
panelLogger.log("COMMAND", event.getPlayer().getName(), event.getMessage());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onServerCommand(ServerCommandEvent event) {
|
||||
panelLogger.log("CONSOLE_COMMAND", "CONSOLE", event.getCommand());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.winniepat.minePanel.logs;
|
||||
|
||||
public record PanelLogEntry(
|
||||
long id,
|
||||
String kind,
|
||||
String source,
|
||||
String message,
|
||||
long createdAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.winniepat.minePanel.logs;
|
||||
|
||||
import de.winniepat.minePanel.integrations.DiscordWebhookService;
|
||||
import de.winniepat.minePanel.persistence.LogRepository;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public final class PanelLogger {
|
||||
|
||||
private final LogRepository logRepository;
|
||||
private final DiscordWebhookService discordWebhookService;
|
||||
|
||||
public PanelLogger(LogRepository logRepository, DiscordWebhookService discordWebhookService) {
|
||||
this.logRepository = logRepository;
|
||||
this.discordWebhookService = discordWebhookService;
|
||||
}
|
||||
|
||||
public synchronized void log(String kind, String source, String message) {
|
||||
Instant now = Instant.now();
|
||||
String safeMessage = message == null ? "" : message;
|
||||
logRepository.appendLog(kind, source, safeMessage);
|
||||
|
||||
discordWebhookService.handlePanelLog(kind, source, safeMessage, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package de.winniepat.minePanel.logs;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import de.winniepat.minePanel.MinePanel;
|
||||
import de.winniepat.minePanel.persistence.*;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.player.*;
|
||||
|
||||
import java.net.*;
|
||||
import java.net.http.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class PlayerActivityListener implements Listener {
|
||||
|
||||
private final MinePanel plugin;
|
||||
private final KnownPlayerRepository knownPlayerRepository;
|
||||
private final PlayerActivityRepository playerActivityRepository;
|
||||
private final JoinLeaveEventRepository joinLeaveEventRepository;
|
||||
private final PanelLogger panelLogger;
|
||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
public PlayerActivityListener(
|
||||
MinePanel plugin,
|
||||
KnownPlayerRepository knownPlayerRepository,
|
||||
PlayerActivityRepository playerActivityRepository,
|
||||
JoinLeaveEventRepository joinLeaveEventRepository,
|
||||
PanelLogger panelLogger
|
||||
) {
|
||||
this.plugin = plugin;
|
||||
this.knownPlayerRepository = knownPlayerRepository;
|
||||
this.playerActivityRepository = playerActivityRepository;
|
||||
this.joinLeaveEventRepository = joinLeaveEventRepository;
|
||||
this.panelLogger = panelLogger;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
UUID uuid = player.getUniqueId();
|
||||
long now = Instant.now().toEpochMilli();
|
||||
|
||||
knownPlayerRepository.upsert(uuid, player.getName(), now);
|
||||
joinLeaveEventRepository.appendJoinEvent(uuid, player.getName(), now);
|
||||
|
||||
String ip = "";
|
||||
if (player.getAddress() != null && player.getAddress().getAddress() != null) {
|
||||
ip = player.getAddress().getAddress().getHostAddress();
|
||||
}
|
||||
playerActivityRepository.onJoin(uuid, now, ip);
|
||||
|
||||
if (!ip.isBlank()) {
|
||||
Player matchingOnlinePlayer = findOtherOnlinePlayerWithSameIp(player, ip);
|
||||
if (matchingOnlinePlayer != null) {
|
||||
panelLogger.log(
|
||||
"SECURITY",
|
||||
"ALT_DETECT",
|
||||
"Possible alt account: " + player.getName()
|
||||
+ " joined with IP " + ip
|
||||
+ " already used by online player " + matchingOnlinePlayer.getName()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ip.isBlank()) {
|
||||
String ipAddress = ip;
|
||||
plugin.schedulerBridge().runAsync(() -> {
|
||||
String country = resolveCountry(ipAddress);
|
||||
playerActivityRepository.updateCountry(uuid, country);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerQuit(PlayerQuitEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
long now = Instant.now().toEpochMilli();
|
||||
knownPlayerRepository.upsert(player.getUniqueId(), player.getName(), now);
|
||||
playerActivityRepository.onQuit(player.getUniqueId(), now);
|
||||
joinLeaveEventRepository.appendLeaveEvent(player.getUniqueId(), player.getName(), now);
|
||||
}
|
||||
|
||||
private String resolveCountry(String ipAddress) {
|
||||
if (isLocalAddress(ipAddress)) {
|
||||
return "Local";
|
||||
}
|
||||
|
||||
try {
|
||||
String encodedIp = URLEncoder.encode(ipAddress, StandardCharsets.UTF_8);
|
||||
String url = "http://ip-api.com/json/" + encodedIp + "?fields=status,country";
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() >= 300) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
JsonObject payload = gson.fromJson(response.body(), JsonObject.class);
|
||||
if (payload == null || !payload.has("status")) {
|
||||
return "Unknown";
|
||||
}
|
||||
if (!"success".equalsIgnoreCase(payload.get("status").getAsString())) {
|
||||
return "Unknown";
|
||||
}
|
||||
if (!payload.has("country") || payload.get("country").isJsonNull()) {
|
||||
return "Unknown";
|
||||
}
|
||||
return payload.get("country").getAsString();
|
||||
} catch (Exception ignored) {
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isLocalAddress(String ipAddress) {
|
||||
try {
|
||||
InetAddress address = InetAddress.getByName(ipAddress);
|
||||
return address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isSiteLocalAddress();
|
||||
} catch (Exception ignored) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private Player findOtherOnlinePlayerWithSameIp(Player joinedPlayer, String ipAddress) {
|
||||
for (Player online : plugin.getServer().getOnlinePlayers()) {
|
||||
if (online.getUniqueId().equals(joinedPlayer.getUniqueId())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String onlineIp = "";
|
||||
if (online.getAddress() != null && online.getAddress().getAddress() != null) {
|
||||
onlineIp = online.getAddress().getAddress().getHostAddress();
|
||||
}
|
||||
|
||||
if (!onlineIp.isBlank() && onlineIp.equals(ipAddress)) {
|
||||
return online;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.winniepat.minePanel.logs;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.stream.*;
|
||||
|
||||
public final class ServerLogService {
|
||||
|
||||
private final Path logDirectory;
|
||||
private final Path latestLogFile;
|
||||
|
||||
public ServerLogService(Path pluginDataFolder) {
|
||||
Path pluginsFolder = pluginDataFolder.getParent();
|
||||
Path serverRoot = pluginsFolder == null ? pluginDataFolder : pluginsFolder.getParent();
|
||||
if (serverRoot == null) {
|
||||
serverRoot = pluginDataFolder;
|
||||
}
|
||||
this.logDirectory = serverRoot.resolve("logs");
|
||||
this.latestLogFile = logDirectory.resolve("latest.log");
|
||||
}
|
||||
|
||||
public List<String> readLatestLines(int lines) {
|
||||
return readLogLines("latest.log", lines);
|
||||
}
|
||||
|
||||
public List<String> readLogLines(String logFileName, int lines) {
|
||||
Path targetFile = resolveLogFile(logFileName);
|
||||
if (targetFile == null || !Files.exists(targetFile)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
int safeLines = Math.max(1, Math.min(lines, 2000));
|
||||
Deque<String> queue = new ArrayDeque<>(safeLines);
|
||||
|
||||
try {
|
||||
for (String line : Files.readAllLines(targetFile, StandardCharsets.UTF_8)) {
|
||||
if (queue.size() == safeLines) {
|
||||
queue.removeFirst();
|
||||
}
|
||||
queue.addLast(line);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
return List.of("Unable to read " + targetFile.getFileName() + ": " + exception.getMessage());
|
||||
}
|
||||
|
||||
return new ArrayList<>(queue);
|
||||
}
|
||||
|
||||
public List<String> listLogFiles() {
|
||||
if (!Files.exists(logDirectory) || !Files.isDirectory(logDirectory)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
try (Stream<Path> files = Files.list(logDirectory)) {
|
||||
return files
|
||||
.filter(Files::isRegularFile)
|
||||
.sorted(Comparator.comparingLong(this::lastModifiedSafe).reversed())
|
||||
.map(path -> path.getFileName().toString())
|
||||
.collect(Collectors.toList());
|
||||
} catch (IOException exception) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveLogFile(String logFileName) {
|
||||
String selected = (logFileName == null || logFileName.isBlank()) ? "latest.log" : logFileName;
|
||||
if (selected.contains("/") || selected.contains("\\")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Path target = logDirectory.resolve(selected).normalize();
|
||||
if (!target.startsWith(logDirectory)) {
|
||||
return null;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private long lastModifiedSafe(Path path) {
|
||||
try {
|
||||
return Files.getLastModifiedTime(path).toMillis();
|
||||
} catch (IOException ignored) {
|
||||
return Long.MIN_VALUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package de.winniepat.minePanel.persistence;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.sql.*;
|
||||
|
||||
public final class Database {
|
||||
|
||||
private final Path databaseFile;
|
||||
|
||||
public Database(Path databaseFile) {
|
||||
this.databaseFile = databaseFile;
|
||||
}
|
||||
|
||||
public void initialize() {
|
||||
try {
|
||||
Files.createDirectories(databaseFile.getParent());
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Could not create database directory", exception);
|
||||
}
|
||||
|
||||
try (Connection connection = getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS users ("
|
||||
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
+ "username TEXT NOT NULL UNIQUE,"
|
||||
+ "password_hash TEXT NOT NULL,"
|
||||
+ "role TEXT NOT NULL,"
|
||||
+ "created_at INTEGER NOT NULL"
|
||||
+ ")");
|
||||
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS user_permissions ("
|
||||
+ "user_id INTEGER NOT NULL,"
|
||||
+ "permission TEXT NOT NULL,"
|
||||
+ "PRIMARY KEY(user_id, permission),"
|
||||
+ "FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||
+ ")");
|
||||
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS sessions ("
|
||||
+ "token TEXT PRIMARY KEY,"
|
||||
+ "user_id INTEGER NOT NULL,"
|
||||
+ "created_at INTEGER NOT NULL,"
|
||||
+ "expires_at INTEGER NOT NULL,"
|
||||
+ "FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||
+ ")");
|
||||
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS oauth_accounts ("
|
||||
+ "user_id INTEGER NOT NULL,"
|
||||
+ "provider TEXT NOT NULL,"
|
||||
+ "provider_user_id TEXT NOT NULL,"
|
||||
+ "display_name TEXT NOT NULL DEFAULT '',"
|
||||
+ "email TEXT NOT NULL DEFAULT '',"
|
||||
+ "avatar_url TEXT NOT NULL DEFAULT '',"
|
||||
+ "linked_at INTEGER NOT NULL,"
|
||||
+ "updated_at INTEGER NOT NULL,"
|
||||
+ "PRIMARY KEY(user_id, provider),"
|
||||
+ "UNIQUE(provider, provider_user_id),"
|
||||
+ "FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||
+ ")");
|
||||
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS oauth_states ("
|
||||
+ "state TEXT PRIMARY KEY,"
|
||||
+ "provider TEXT NOT NULL,"
|
||||
+ "mode TEXT NOT NULL,"
|
||||
+ "user_id INTEGER,"
|
||||
+ "created_at INTEGER NOT NULL,"
|
||||
+ "expires_at INTEGER NOT NULL"
|
||||
+ ")");
|
||||
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS panel_logs ("
|
||||
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
+ "kind TEXT NOT NULL,"
|
||||
+ "source TEXT NOT NULL,"
|
||||
+ "message TEXT NOT NULL,"
|
||||
+ "created_at INTEGER NOT NULL"
|
||||
+ ")");
|
||||
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS known_players ("
|
||||
+ "uuid TEXT PRIMARY KEY,"
|
||||
+ "username TEXT NOT NULL,"
|
||||
+ "last_seen_at INTEGER NOT NULL"
|
||||
+ ")");
|
||||
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS player_activity ("
|
||||
+ "uuid TEXT PRIMARY KEY,"
|
||||
+ "first_joined INTEGER NOT NULL DEFAULT 0,"
|
||||
+ "last_seen INTEGER NOT NULL DEFAULT 0,"
|
||||
+ "total_playtime_seconds INTEGER NOT NULL DEFAULT 0,"
|
||||
+ "total_sessions INTEGER NOT NULL DEFAULT 0,"
|
||||
+ "current_session_start INTEGER NOT NULL DEFAULT 0,"
|
||||
+ "last_ip TEXT NOT NULL DEFAULT '',"
|
||||
+ "last_country TEXT NOT NULL DEFAULT ''"
|
||||
+ ")");
|
||||
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS join_leave_events ("
|
||||
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
+ "event_type TEXT NOT NULL,"
|
||||
+ "player_uuid TEXT NOT NULL,"
|
||||
+ "player_name TEXT NOT NULL,"
|
||||
+ "created_at INTEGER NOT NULL"
|
||||
+ ")");
|
||||
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS discord_webhook_config ("
|
||||
+ "id INTEGER PRIMARY KEY,"
|
||||
+ "enabled INTEGER NOT NULL,"
|
||||
+ "webhook_url TEXT NOT NULL,"
|
||||
+ "use_embed INTEGER NOT NULL,"
|
||||
+ "bot_name TEXT NOT NULL,"
|
||||
+ "message_template TEXT NOT NULL,"
|
||||
+ "embed_title_template TEXT NOT NULL,"
|
||||
+ "log_chat INTEGER NOT NULL,"
|
||||
+ "log_commands INTEGER NOT NULL,"
|
||||
+ "log_auth INTEGER NOT NULL,"
|
||||
+ "log_audit INTEGER NOT NULL,"
|
||||
+ "log_security INTEGER NOT NULL DEFAULT 1,"
|
||||
+ "log_console_response INTEGER NOT NULL,"
|
||||
+ "log_system INTEGER NOT NULL"
|
||||
+ ")");
|
||||
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS extension_settings ("
|
||||
+ "extension_id TEXT PRIMARY KEY,"
|
||||
+ "settings_json TEXT NOT NULL DEFAULT '{}',"
|
||||
+ "updated_at INTEGER NOT NULL DEFAULT 0"
|
||||
+ ")");
|
||||
|
||||
try {
|
||||
statement.execute("ALTER TABLE discord_webhook_config ADD COLUMN log_security INTEGER NOT NULL DEFAULT 1");
|
||||
} catch (SQLException ignored) {
|
||||
// Column already exists on upgraded installs.
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not initialize database", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Connection getConnection() throws SQLException {
|
||||
return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.toAbsolutePath());
|
||||
}
|
||||
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.winniepat.minePanel.persistence;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class ExtensionSettingsRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public ExtensionSettingsRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Optional<String> findSettingsJson(String extensionId) {
|
||||
String normalizedId = normalizeExtensionId(extensionId);
|
||||
if (normalizedId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String sql = "SELECT settings_json FROM extension_settings WHERE extension_id = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, normalizedId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return Optional.ofNullable(resultSet.getString("settings_json"));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not read extension settings", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void saveSettingsJson(String extensionId, String settingsJson, long updatedAtMillis) {
|
||||
String normalizedId = normalizeExtensionId(extensionId);
|
||||
if (normalizedId.isBlank()) {
|
||||
throw new IllegalArgumentException("invalid_extension_id");
|
||||
}
|
||||
|
||||
String sql = "INSERT INTO extension_settings(extension_id, settings_json, updated_at) VALUES (?, ?, ?) "
|
||||
+ "ON CONFLICT(extension_id) DO UPDATE SET "
|
||||
+ "settings_json = excluded.settings_json, "
|
||||
+ "updated_at = excluded.updated_at";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, normalizedId);
|
||||
statement.setString(2, settingsJson == null || settingsJson.isBlank() ? "{}" : settingsJson);
|
||||
statement.setLong(3, Math.max(0L, updatedAtMillis));
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not save extension settings", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeExtensionId(String extensionId) {
|
||||
if (extensionId == null || extensionId.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return extensionId.trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.winniepat.minePanel.persistence;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
|
||||
public final class JoinLeaveEventRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public JoinLeaveEventRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public void appendJoinEvent(UUID uuid, String username, long createdAt) {
|
||||
appendEvent("JOIN", uuid, username, createdAt);
|
||||
}
|
||||
|
||||
public void appendLeaveEvent(UUID uuid, String username, long createdAt) {
|
||||
appendEvent("LEAVE", uuid, username, createdAt);
|
||||
}
|
||||
|
||||
public List<JoinLeaveEvent> listEventsSince(long sinceMillis) {
|
||||
String sql = "SELECT event_type, player_uuid, player_name, created_at "
|
||||
+ "FROM join_leave_events WHERE created_at >= ? ORDER BY created_at ASC";
|
||||
List<JoinLeaveEvent> events = new ArrayList<>();
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, Math.max(0L, sinceMillis));
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
events.add(new JoinLeaveEvent(
|
||||
resultSet.getString("event_type"),
|
||||
UUID.fromString(resultSet.getString("player_uuid")),
|
||||
resultSet.getString("player_name"),
|
||||
resultSet.getLong("created_at")
|
||||
));
|
||||
}
|
||||
}
|
||||
return events;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not list join/leave events", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendEvent(String eventType, UUID uuid, String username, long createdAt) {
|
||||
if (uuid == null || username == null || username.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String sql = "INSERT INTO join_leave_events(event_type, player_uuid, player_name, created_at) VALUES (?, ?, ?, ?)";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, eventType);
|
||||
statement.setString(2, uuid.toString());
|
||||
statement.setString(3, username);
|
||||
statement.setLong(4, createdAt);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not append join/leave event", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public record JoinLeaveEvent(String eventType, UUID playerUuid, String playerName, long createdAt) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.winniepat.minePanel.persistence;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record KnownPlayer(
|
||||
UUID uuid,
|
||||
String username,
|
||||
long lastSeenAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package de.winniepat.minePanel.persistence;
|
||||
|
||||
import java.sql.*;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
public final class KnownPlayerRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public KnownPlayerRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public void upsert(UUID uuid, String username) {
|
||||
upsert(uuid, username, Instant.now().toEpochMilli());
|
||||
}
|
||||
|
||||
public void upsert(UUID uuid, String username, long lastSeenAt) {
|
||||
if (uuid == null || username == null || username.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String sql = "INSERT INTO known_players(uuid, username, last_seen_at) VALUES (?, ?, ?) "
|
||||
+ "ON CONFLICT(uuid) DO UPDATE SET username = excluded.username, last_seen_at = excluded.last_seen_at";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, uuid.toString());
|
||||
statement.setString(2, username);
|
||||
statement.setLong(3, lastSeenAt);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not upsert known player", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public List<KnownPlayer> findAll() {
|
||||
String sql = "SELECT uuid, username, last_seen_at FROM known_players";
|
||||
List<KnownPlayer> players = new ArrayList<>();
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
players.add(new KnownPlayer(
|
||||
UUID.fromString(resultSet.getString("uuid")),
|
||||
resultSet.getString("username"),
|
||||
resultSet.getLong("last_seen_at")
|
||||
));
|
||||
}
|
||||
return players;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not list known players", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<KnownPlayer> findByUuid(UUID uuid) {
|
||||
String sql = "SELECT uuid, username, last_seen_at FROM known_players WHERE uuid = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, uuid.toString());
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return Optional.of(new KnownPlayer(
|
||||
UUID.fromString(resultSet.getString("uuid")),
|
||||
resultSet.getString("username"),
|
||||
resultSet.getLong("last_seen_at")
|
||||
));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not find known player", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<KnownPlayer> findByUsername(String username) {
|
||||
if (username == null || username.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String sql = "SELECT uuid, username, last_seen_at FROM known_players WHERE LOWER(username) = LOWER(?) LIMIT 1";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, username.trim());
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return Optional.of(new KnownPlayer(
|
||||
UUID.fromString(resultSet.getString("uuid")),
|
||||
resultSet.getString("username"),
|
||||
resultSet.getLong("last_seen_at")
|
||||
));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not find known player by username", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package de.winniepat.minePanel.persistence;
|
||||
|
||||
import de.winniepat.minePanel.logs.PanelLogEntry;
|
||||
|
||||
import java.sql.*;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
public final class LogRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public LogRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public void appendLog(String kind, String source, String message) {
|
||||
String sql = "INSERT INTO panel_logs(kind, source, message, created_at) VALUES (?, ?, ?, ?)";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, kind);
|
||||
statement.setString(2, source);
|
||||
statement.setString(3, message);
|
||||
statement.setLong(4, Instant.now().toEpochMilli());
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not append panel log", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public List<PanelLogEntry> recentLogs(int limit) {
|
||||
int safeLimit = Math.max(1, Math.min(limit, 1000));
|
||||
String sql = "SELECT id, kind, source, message, created_at FROM panel_logs ORDER BY id DESC LIMIT ?";
|
||||
List<PanelLogEntry> entries = new ArrayList<>();
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, safeLimit);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
entries.add(new PanelLogEntry(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getString("kind"),
|
||||
resultSet.getString("source"),
|
||||
resultSet.getString("message"),
|
||||
resultSet.getLong("created_at")
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not query panel logs", exception);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
public List<PanelLogEntry> allLogsAscending() {
|
||||
String sql = "SELECT id, kind, source, message, created_at FROM panel_logs ORDER BY id ASC";
|
||||
List<PanelLogEntry> entries = new ArrayList<>();
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
entries.add(new PanelLogEntry(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getString("kind"),
|
||||
resultSet.getString("source"),
|
||||
resultSet.getString("message"),
|
||||
resultSet.getLong("created_at")
|
||||
));
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not query all panel logs", exception);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
public long latestLogId() {
|
||||
String sql = "SELECT COALESCE(MAX(id), 0) AS latest_id FROM panel_logs";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.getLong("latest_id");
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not get latest panel log id", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void clearLogs() {
|
||||
String sql = "DELETE FROM panel_logs";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not clear panel logs", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package de.winniepat.minePanel.persistence;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class OAuthAccountRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public OAuthAccountRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Optional<Long> findUserIdByProviderSubject(String provider, String providerUserId) {
|
||||
String normalizedProvider = normalizeProvider(provider);
|
||||
String sql = "SELECT user_id FROM oauth_accounts WHERE provider = ? AND provider_user_id = ?";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, normalizedProvider);
|
||||
statement.setString(2, providerUserId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return Optional.of(resultSet.getLong("user_id"));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not query OAuth account by provider subject", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<OAuthAccountLink> findByUserAndProvider(long userId, String provider) {
|
||||
String normalizedProvider = normalizeProvider(provider);
|
||||
String sql = "SELECT user_id, provider, provider_user_id, display_name, email, avatar_url, linked_at, updated_at "
|
||||
+ "FROM oauth_accounts WHERE user_id = ? AND provider = ?";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, userId);
|
||||
statement.setString(2, normalizedProvider);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return Optional.of(map(resultSet));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not query OAuth account by user and provider", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public List<OAuthAccountLink> listByUserId(long userId) {
|
||||
String sql = "SELECT user_id, provider, provider_user_id, display_name, email, avatar_url, linked_at, updated_at "
|
||||
+ "FROM oauth_accounts WHERE user_id = ? ORDER BY provider ASC";
|
||||
|
||||
List<OAuthAccountLink> links = new ArrayList<>();
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, userId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
links.add(map(resultSet));
|
||||
}
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not list OAuth links for user", exception);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
public void upsertLink(long userId, String provider, String providerUserId, String displayName, String email, String avatarUrl) {
|
||||
String normalizedProvider = normalizeProvider(provider);
|
||||
long now = Instant.now().toEpochMilli();
|
||||
String sql = "INSERT INTO oauth_accounts(user_id, provider, provider_user_id, display_name, email, avatar_url, linked_at, updated_at) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
+ "ON CONFLICT(user_id, provider) DO UPDATE SET "
|
||||
+ "provider_user_id = excluded.provider_user_id, "
|
||||
+ "display_name = excluded.display_name, "
|
||||
+ "email = excluded.email, "
|
||||
+ "avatar_url = excluded.avatar_url, "
|
||||
+ "updated_at = excluded.updated_at";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, userId);
|
||||
statement.setString(2, normalizedProvider);
|
||||
statement.setString(3, providerUserId);
|
||||
statement.setString(4, safe(displayName));
|
||||
statement.setString(5, safe(email));
|
||||
statement.setString(6, safe(avatarUrl));
|
||||
statement.setLong(7, now);
|
||||
statement.setLong(8, now);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not upsert OAuth account link", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean unlink(long userId, String provider) {
|
||||
String normalizedProvider = normalizeProvider(provider);
|
||||
String sql = "DELETE FROM oauth_accounts WHERE user_id = ? AND provider = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, userId);
|
||||
statement.setString(2, normalizedProvider);
|
||||
return statement.executeUpdate() > 0;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not unlink OAuth account", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private OAuthAccountLink map(ResultSet resultSet) throws SQLException {
|
||||
return new OAuthAccountLink(
|
||||
resultSet.getLong("user_id"),
|
||||
resultSet.getString("provider"),
|
||||
resultSet.getString("provider_user_id"),
|
||||
resultSet.getString("display_name"),
|
||||
resultSet.getString("email"),
|
||||
resultSet.getString("avatar_url"),
|
||||
resultSet.getLong("linked_at"),
|
||||
resultSet.getLong("updated_at")
|
||||
);
|
||||
}
|
||||
|
||||
private String normalizeProvider(String provider) {
|
||||
return provider == null ? "" : provider.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String safe(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
public record OAuthAccountLink(
|
||||
long userId,
|
||||
String provider,
|
||||
String providerUserId,
|
||||
String displayName,
|
||||
String email,
|
||||
String avatarUrl,
|
||||
long linkedAt,
|
||||
long updatedAt
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.winniepat.minePanel.persistence;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class OAuthStateRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public OAuthStateRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public void createState(String state, String provider, String mode, Long userId, long expiresAtMillis) {
|
||||
cleanupExpired();
|
||||
|
||||
String sql = "INSERT INTO oauth_states(state, provider, mode, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)";
|
||||
long now = Instant.now().toEpochMilli();
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, state);
|
||||
statement.setString(2, normalize(provider));
|
||||
statement.setString(3, normalize(mode));
|
||||
if (userId == null) {
|
||||
statement.setNull(4, java.sql.Types.INTEGER);
|
||||
} else {
|
||||
statement.setLong(4, userId);
|
||||
}
|
||||
statement.setLong(5, now);
|
||||
statement.setLong(6, expiresAtMillis);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not create OAuth state", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<OAuthState> consumeState(String state, String provider, String mode) {
|
||||
String normalizedProvider = normalize(provider);
|
||||
String normalizedMode = normalize(mode);
|
||||
String selectSql = "SELECT state, provider, mode, user_id, created_at, expires_at FROM oauth_states WHERE state = ? AND provider = ? AND mode = ?";
|
||||
String deleteSql = "DELETE FROM oauth_states WHERE state = ?";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement selectStatement = connection.prepareStatement(selectSql);
|
||||
PreparedStatement deleteStatement = connection.prepareStatement(deleteSql)) {
|
||||
connection.setAutoCommit(false);
|
||||
|
||||
selectStatement.setString(1, state);
|
||||
selectStatement.setString(2, normalizedProvider);
|
||||
selectStatement.setString(3, normalizedMode);
|
||||
|
||||
OAuthState oauthState = null;
|
||||
try (ResultSet resultSet = selectStatement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
Long userId = null;
|
||||
long rawUserId = resultSet.getLong("user_id");
|
||||
if (!resultSet.wasNull()) {
|
||||
userId = rawUserId;
|
||||
}
|
||||
oauthState = new OAuthState(
|
||||
resultSet.getString("state"),
|
||||
resultSet.getString("provider"),
|
||||
resultSet.getString("mode"),
|
||||
userId,
|
||||
resultSet.getLong("created_at"),
|
||||
resultSet.getLong("expires_at")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (oauthState == null) {
|
||||
connection.rollback();
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
deleteStatement.setString(1, state);
|
||||
deleteStatement.executeUpdate();
|
||||
|
||||
if (oauthState.expiresAtMillis() <= Instant.now().toEpochMilli()) {
|
||||
connection.commit();
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
connection.commit();
|
||||
return Optional.of(oauthState);
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not consume OAuth state", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void cleanupExpired() {
|
||||
String sql = "DELETE FROM oauth_states WHERE expires_at <= ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, Instant.now().toEpochMilli());
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not cleanup OAuth states", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalize(String raw) {
|
||||
return raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public record OAuthState(
|
||||
String state,
|
||||
String provider,
|
||||
String mode,
|
||||
Long userId,
|
||||
long createdAtMillis,
|
||||
long expiresAtMillis
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.winniepat.minePanel.persistence;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record PlayerActivity(
|
||||
UUID uuid,
|
||||
long firstJoined,
|
||||
long lastSeen,
|
||||
long totalPlaytimeSeconds,
|
||||
long totalSessions,
|
||||
long currentSessionStart,
|
||||
String lastIp,
|
||||
String lastCountry
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package de.winniepat.minePanel.persistence;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
|
||||
public final class PlayerActivityRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public PlayerActivityRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public void ensureFromOffline(UUID uuid, long firstJoined, long lastSeen) {
|
||||
if (uuid == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long safeFirstJoined = Math.max(0L, firstJoined);
|
||||
long safeLastSeen = Math.max(0L, lastSeen);
|
||||
String sql = "INSERT INTO player_activity(uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country) "
|
||||
+ "VALUES (?, ?, ?, 0, 0, 0, '', '') "
|
||||
+ "ON CONFLICT(uuid) DO UPDATE SET "
|
||||
+ "first_joined = CASE WHEN player_activity.first_joined = 0 THEN excluded.first_joined ELSE player_activity.first_joined END, "
|
||||
+ "last_seen = MAX(player_activity.last_seen, excluded.last_seen)";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, uuid.toString());
|
||||
statement.setLong(2, safeFirstJoined);
|
||||
statement.setLong(3, safeLastSeen);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not seed player activity", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void onJoin(UUID uuid, long joinedAt, String ipAddress) {
|
||||
if (uuid == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String safeIp = ipAddress == null ? "" : ipAddress;
|
||||
String sql = "INSERT INTO player_activity(uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country) "
|
||||
+ "VALUES (?, ?, ?, 0, 1, ?, ?, '') "
|
||||
+ "ON CONFLICT(uuid) DO UPDATE SET "
|
||||
+ "first_joined = CASE WHEN player_activity.first_joined = 0 THEN excluded.first_joined ELSE player_activity.first_joined END, "
|
||||
+ "last_seen = excluded.last_seen, "
|
||||
+ "total_sessions = player_activity.total_sessions + 1, "
|
||||
+ "current_session_start = CASE WHEN player_activity.current_session_start > 0 THEN player_activity.current_session_start ELSE excluded.current_session_start END, "
|
||||
+ "last_ip = excluded.last_ip";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, uuid.toString());
|
||||
statement.setLong(2, joinedAt);
|
||||
statement.setLong(3, joinedAt);
|
||||
statement.setLong(4, joinedAt);
|
||||
statement.setString(5, safeIp);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not update player activity on join", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void onQuit(UUID uuid, long quitAt) {
|
||||
if (uuid == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String sql = "UPDATE player_activity SET "
|
||||
+ "last_seen = ?, "
|
||||
+ "total_playtime_seconds = total_playtime_seconds + CASE "
|
||||
+ "WHEN current_session_start > 0 AND ? > current_session_start THEN (? - current_session_start) / 1000 ELSE 0 END, "
|
||||
+ "current_session_start = 0 "
|
||||
+ "WHERE uuid = ?";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, quitAt);
|
||||
statement.setLong(2, quitAt);
|
||||
statement.setLong(3, quitAt);
|
||||
statement.setString(4, uuid.toString());
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not update player activity on quit", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateCountry(UUID uuid, String country) {
|
||||
if (uuid == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String sql = "UPDATE player_activity SET last_country = ? WHERE uuid = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, country == null ? "" : country);
|
||||
statement.setString(2, uuid.toString());
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not update player country", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<PlayerActivity> findByUuid(UUID uuid) {
|
||||
String sql = "SELECT uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country "
|
||||
+ "FROM player_activity WHERE uuid = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, uuid.toString());
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return Optional.of(read(resultSet));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not read player activity", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Map<UUID, PlayerActivity> findAllByUuid() {
|
||||
String sql = "SELECT uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country FROM player_activity";
|
||||
Map<UUID, PlayerActivity> players = new HashMap<>();
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
PlayerActivity entry = read(resultSet);
|
||||
players.put(entry.uuid(), entry);
|
||||
}
|
||||
return players;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not list player activity", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private PlayerActivity read(ResultSet resultSet) throws SQLException {
|
||||
return new PlayerActivity(
|
||||
UUID.fromString(resultSet.getString("uuid")),
|
||||
resultSet.getLong("first_joined"),
|
||||
resultSet.getLong("last_seen"),
|
||||
resultSet.getLong("total_playtime_seconds"),
|
||||
resultSet.getLong("total_sessions"),
|
||||
resultSet.getLong("current_session_start"),
|
||||
resultSet.getString("last_ip"),
|
||||
resultSet.getString("last_country")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
package de.winniepat.minePanel.persistence;
|
||||
|
||||
import de.winniepat.minePanel.users.*;
|
||||
|
||||
import java.sql.*;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
public final class UserRepository {
|
||||
|
||||
private final Database database;
|
||||
|
||||
public UserRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public long countUsers() {
|
||||
String sql = "SELECT COUNT(*) AS amount FROM users";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.getLong("amount");
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not count users", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public long countOwners() {
|
||||
String sql = "SELECT COUNT(*) AS amount FROM users WHERE role = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, UserRole.OWNER.name());
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.getLong("amount");
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not count owners", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public int demoteExtraOwnersToAdmin() {
|
||||
String selectSql = "SELECT id FROM users WHERE role = ? ORDER BY id ASC";
|
||||
String updateSql = "UPDATE users SET role = ? WHERE id = ?";
|
||||
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement selectStatement = connection.prepareStatement(selectSql);
|
||||
PreparedStatement updateStatement = connection.prepareStatement(updateSql)) {
|
||||
connection.setAutoCommit(false);
|
||||
selectStatement.setString(1, UserRole.OWNER.name());
|
||||
|
||||
List<Long> ownerIds = new ArrayList<>();
|
||||
try (ResultSet resultSet = selectStatement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
ownerIds.add(resultSet.getLong("id"));
|
||||
}
|
||||
}
|
||||
|
||||
if (ownerIds.size() <= 1) {
|
||||
connection.rollback();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
for (int i = 1; i < ownerIds.size(); i++) {
|
||||
long userId = ownerIds.get(i);
|
||||
updateStatement.setString(1, UserRole.ADMIN.name());
|
||||
updateStatement.setLong(2, userId);
|
||||
if (updateStatement.executeUpdate() > 0) {
|
||||
savePermissions(connection, userId, UserRole.ADMIN.defaultPermissions());
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
connection.commit();
|
||||
return updated;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not normalize owner accounts", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public PanelUser createUser(String username, String passwordHash, UserRole role) {
|
||||
return createUser(username, passwordHash, role, role.defaultPermissions());
|
||||
}
|
||||
|
||||
public PanelUser createUser(String username, String passwordHash, UserRole role, Set<PanelPermission> permissions) {
|
||||
String sql = "INSERT INTO users(username, password_hash, role, created_at) VALUES (?, ?, ?, ?)";
|
||||
long createdAt = Instant.now().toEpochMilli();
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS)) {
|
||||
connection.setAutoCommit(false);
|
||||
statement.setString(1, username);
|
||||
statement.setString(2, passwordHash);
|
||||
statement.setString(3, role.name());
|
||||
statement.setLong(4, createdAt);
|
||||
statement.executeUpdate();
|
||||
|
||||
long userId;
|
||||
try (ResultSet keys = statement.getGeneratedKeys()) {
|
||||
if (keys.next()) {
|
||||
userId = keys.getLong(1);
|
||||
} else {
|
||||
throw new IllegalStateException("Could not create user (missing generated key)");
|
||||
}
|
||||
}
|
||||
|
||||
Set<PanelPermission> sanitized = sanitizePermissions(permissions, role);
|
||||
savePermissions(connection, userId, sanitized);
|
||||
connection.commit();
|
||||
return new PanelUser(userId, username, role, sanitized, createdAt);
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not create user", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<PanelUserAuth> findByUsername(String username) {
|
||||
String sql = "SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, username);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
PanelUser user = mapUser(connection, resultSet);
|
||||
String passwordHash = resultSet.getString("password_hash");
|
||||
return Optional.of(new PanelUserAuth(user, passwordHash));
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not find user by username", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<PanelUser> findById(long userId) {
|
||||
String sql = "SELECT id, username, role, created_at FROM users WHERE id = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, userId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return Optional.of(mapUser(connection, resultSet));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not find user by id", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public List<PanelUser> findAllUsers() {
|
||||
String sql = "SELECT id, username, role, created_at FROM users ORDER BY id ASC";
|
||||
String permissionSql = "SELECT user_id, permission FROM user_permissions ORDER BY user_id ASC";
|
||||
List<PanelUser> users = new ArrayList<>();
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement permissionStatement = connection.prepareStatement(permissionSql);
|
||||
ResultSet permissionResultSet = permissionStatement.executeQuery();
|
||||
PreparedStatement statement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
Map<Long, Set<PanelPermission>> permissionsByUser = new HashMap<>();
|
||||
while (permissionResultSet.next()) {
|
||||
long userId = permissionResultSet.getLong("user_id");
|
||||
String rawPermission = permissionResultSet.getString("permission");
|
||||
PanelPermission permission;
|
||||
try {
|
||||
permission = PanelPermission.valueOf(rawPermission);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
continue;
|
||||
}
|
||||
permissionsByUser.computeIfAbsent(userId, ignored -> EnumSet.noneOf(PanelPermission.class)).add(permission);
|
||||
}
|
||||
|
||||
while (resultSet.next()) {
|
||||
long userId = resultSet.getLong("id");
|
||||
UserRole role = UserRole.fromString(resultSet.getString("role"));
|
||||
Set<PanelPermission> permissions = permissionsByUser.getOrDefault(userId, role.defaultPermissions());
|
||||
users.add(new PanelUser(
|
||||
userId,
|
||||
resultSet.getString("username"),
|
||||
role,
|
||||
EnumSet.copyOf(permissions),
|
||||
resultSet.getLong("created_at")
|
||||
));
|
||||
}
|
||||
return users;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not list users", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean updateRole(long userId, UserRole role) {
|
||||
String sql = "UPDATE users SET role = ? WHERE id = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
connection.setAutoCommit(false);
|
||||
statement.setString(1, role.name());
|
||||
statement.setLong(2, userId);
|
||||
boolean changed = statement.executeUpdate() > 0;
|
||||
if (!changed) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
savePermissions(connection, userId, role.defaultPermissions());
|
||||
connection.commit();
|
||||
return true;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not update user role", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean updatePermissions(long userId, Set<PanelPermission> permissions) {
|
||||
Optional<PanelUser> target = findById(userId);
|
||||
if (target.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Set<PanelPermission> sanitized = sanitizePermissions(permissions, target.get().role());
|
||||
String sql = "SELECT id FROM users WHERE id = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
connection.setAutoCommit(false);
|
||||
statement.setLong(1, userId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next()) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
savePermissions(connection, userId, sanitized);
|
||||
connection.commit();
|
||||
return true;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not update user permissions", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean deleteUser(long userId) {
|
||||
String sql = "DELETE FROM users WHERE id = ?";
|
||||
try (Connection connection = database.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, userId);
|
||||
return statement.executeUpdate() > 0;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Could not delete user", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private PanelUser mapUser(Connection connection, ResultSet resultSet) throws SQLException {
|
||||
long userId = resultSet.getLong("id");
|
||||
UserRole role = UserRole.fromString(resultSet.getString("role"));
|
||||
return new PanelUser(
|
||||
userId,
|
||||
resultSet.getString("username"),
|
||||
role,
|
||||
loadPermissions(connection, userId, role),
|
||||
resultSet.getLong("created_at")
|
||||
);
|
||||
}
|
||||
|
||||
private Set<PanelPermission> loadPermissions(Connection connection, long userId, UserRole role) throws SQLException {
|
||||
String sql = "SELECT permission FROM user_permissions WHERE user_id = ?";
|
||||
Set<PanelPermission> permissions = EnumSet.noneOf(PanelPermission.class);
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, userId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
String rawPermission = resultSet.getString("permission");
|
||||
try {
|
||||
permissions.add(PanelPermission.valueOf(rawPermission));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Ignore unknown permissions from older/newer versions.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (permissions.isEmpty()) {
|
||||
return role.defaultPermissions();
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
private void savePermissions(Connection connection, long userId, Set<PanelPermission> permissions) throws SQLException {
|
||||
try (PreparedStatement deleteStatement = connection.prepareStatement("DELETE FROM user_permissions WHERE user_id = ?")) {
|
||||
deleteStatement.setLong(1, userId);
|
||||
deleteStatement.executeUpdate();
|
||||
}
|
||||
|
||||
String insertSql = "INSERT INTO user_permissions(user_id, permission) VALUES (?, ?)";
|
||||
try (PreparedStatement insertStatement = connection.prepareStatement(insertSql)) {
|
||||
for (PanelPermission permission : permissions) {
|
||||
insertStatement.setLong(1, userId);
|
||||
insertStatement.setString(2, permission.name());
|
||||
insertStatement.addBatch();
|
||||
}
|
||||
insertStatement.executeBatch();
|
||||
}
|
||||
}
|
||||
|
||||
private Set<PanelPermission> sanitizePermissions(Set<PanelPermission> permissions, UserRole role) {
|
||||
if (role == UserRole.OWNER) {
|
||||
return EnumSet.allOf(PanelPermission.class);
|
||||
}
|
||||
|
||||
Set<PanelPermission> source = permissions == null || permissions.isEmpty() ? role.defaultPermissions() : permissions;
|
||||
Set<PanelPermission> sanitized = EnumSet.noneOf(PanelPermission.class);
|
||||
for (PanelPermission permission : source) {
|
||||
if (permission == null || !permission.assignable()) {
|
||||
continue;
|
||||
}
|
||||
sanitized.add(permission);
|
||||
}
|
||||
sanitized.add(PanelPermission.ACCESS_PANEL);
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.winniepat.minePanel.users;
|
||||
|
||||
public enum PanelPermission {
|
||||
// Legacy keys kept for compatibility with older route guards.
|
||||
VIEW_DASHBOARD("Legacy", "View Dashboard", null, false),
|
||||
VIEW_LOGS("Legacy", "View Logs", null, false),
|
||||
|
||||
ACCESS_PANEL("Panel", "Access Panel", null, true),
|
||||
|
||||
VIEW_OVERVIEW("Server", "View Overview", null, true),
|
||||
VIEW_CONSOLE("Server", "View Console", null, true),
|
||||
SEND_CONSOLE("Server", "Send Console Commands", null, true),
|
||||
VIEW_RESOURCES("Server", "View Resources", null, true),
|
||||
VIEW_PLAYERS("Server", "View Players", null, true),
|
||||
MANAGE_PLAYERS("Server", "Manage Players", null, true),
|
||||
VIEW_BANS("Server", "View Bans", null, true),
|
||||
MANAGE_BANS("Server", "Manage Bans", null, true),
|
||||
VIEW_PLUGINS("Server", "View Plugins", null, true),
|
||||
MANAGE_PLUGINS("Server", "Install Plugins", null, true),
|
||||
|
||||
VIEW_USERS("Panel", "View Users", null, true),
|
||||
MANAGE_USERS("Panel", "Manage Users", null, true),
|
||||
VIEW_DISCORD_WEBHOOK("Panel", "View Discord Webhook", null, true),
|
||||
MANAGE_DISCORD_WEBHOOK("Panel", "Manage Discord Webhook", null, true),
|
||||
VIEW_THEMES("Panel", "View Themes", null, true),
|
||||
MANAGE_THEMES("Panel", "Manage Themes", null, true),
|
||||
VIEW_EXTENSIONS("Panel", "View Extensions", null, true),
|
||||
MANAGE_EXTENSIONS("Panel", "Manage Extensions", null, true),
|
||||
|
||||
VIEW_BACKUPS("Extensions", "View Backups", "world-backups", true),
|
||||
MANAGE_BACKUPS("Extensions", "Manage Backups", "world-backups", true),
|
||||
VIEW_REPORTS("Extensions", "View Reports", "report-system", true),
|
||||
MANAGE_REPORTS("Extensions", "Manage Reports", "report-system", true),
|
||||
VIEW_TICKETS("Extensions", "View Tickets", "ticket-system", true),
|
||||
MANAGE_TICKETS("Extensions", "Manage Tickets", "ticket-system", true),
|
||||
VIEW_PLAYER_MANAGEMENT("Extensions", "View Player Management", "player-management", true),
|
||||
MANAGE_PLAYER_MANAGEMENT("Extensions", "Manage Player Management", "player-management", true),
|
||||
VIEW_PLAYER_STATS("Extensions", "View Player Stats", "player-stats", true),
|
||||
VIEW_LUCKPERMS("Extensions", "View LuckPerms Data", "luckperms", true),
|
||||
VIEW_AIRSTRIKE("Extensions", "View Airstrike", "airstrike", true),
|
||||
MANAGE_AIRSTRIKE("Extensions", "Launch Airstrike", "airstrike", true),
|
||||
VIEW_MAINTENANCE("Extensions", "View Maintenance", "maintenance", true),
|
||||
MANAGE_MAINTENANCE("Extensions", "Manage Maintenance", "maintenance", true),
|
||||
VIEW_WHITELIST("Extensions", "View Whitelist", "whitelist", true),
|
||||
MANAGE_WHITELIST("Extensions", "Manage Whitelist", "whitelist", true),
|
||||
VIEW_ANNOUNCEMENTS("Extensions", "View Announcements", "announcements", true),
|
||||
MANAGE_ANNOUNCEMENTS("Extensions", "Manage Announcements", "announcements", true);
|
||||
|
||||
private final String category;
|
||||
private final String label;
|
||||
private final String extensionId;
|
||||
private final boolean assignable;
|
||||
|
||||
PanelPermission(String category, String label, String extensionId, boolean assignable) {
|
||||
this.category = category;
|
||||
this.label = label;
|
||||
this.extensionId = extensionId;
|
||||
this.assignable = assignable;
|
||||
}
|
||||
|
||||
public String category() {
|
||||
return category;
|
||||
}
|
||||
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public String extensionId() {
|
||||
return extensionId;
|
||||
}
|
||||
|
||||
public boolean assignable() {
|
||||
return assignable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.winniepat.minePanel.users;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public record PanelUser(
|
||||
long id,
|
||||
String username,
|
||||
UserRole role,
|
||||
Set<PanelPermission> permissions,
|
||||
long createdAt
|
||||
) {
|
||||
public boolean hasPermission(PanelPermission permission) {
|
||||
if (role == UserRole.OWNER || permissions.contains(permission)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (permission != null) {
|
||||
String name = permission.name();
|
||||
if (name.startsWith("VIEW_")) {
|
||||
String manageName = "MANAGE_" + name.substring("VIEW_".length());
|
||||
try {
|
||||
PanelPermission managePermission = PanelPermission.valueOf(manageName);
|
||||
if (permissions.contains(managePermission)) {
|
||||
return true;
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == PanelPermission.VIEW_DASHBOARD) {
|
||||
return permissions.contains(PanelPermission.ACCESS_PANEL);
|
||||
}
|
||||
if (permission == PanelPermission.VIEW_LOGS) {
|
||||
return permissions.contains(PanelPermission.VIEW_CONSOLE);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.winniepat.minePanel.users;
|
||||
|
||||
public record PanelUserAuth(
|
||||
PanelUser user,
|
||||
String passwordHash
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package de.winniepat.minePanel.users;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public enum UserRole {
|
||||
OWNER(EnumSet.allOf(PanelPermission.class)),
|
||||
ADMIN(EnumSet.of(
|
||||
PanelPermission.ACCESS_PANEL,
|
||||
PanelPermission.VIEW_OVERVIEW,
|
||||
PanelPermission.VIEW_CONSOLE,
|
||||
PanelPermission.SEND_CONSOLE,
|
||||
PanelPermission.VIEW_RESOURCES,
|
||||
PanelPermission.VIEW_PLAYERS,
|
||||
PanelPermission.MANAGE_PLAYERS,
|
||||
PanelPermission.VIEW_BANS,
|
||||
PanelPermission.MANAGE_BANS,
|
||||
PanelPermission.VIEW_PLUGINS,
|
||||
PanelPermission.MANAGE_PLUGINS,
|
||||
PanelPermission.VIEW_USERS,
|
||||
PanelPermission.VIEW_DISCORD_WEBHOOK,
|
||||
PanelPermission.MANAGE_DISCORD_WEBHOOK,
|
||||
PanelPermission.VIEW_THEMES,
|
||||
PanelPermission.VIEW_EXTENSIONS,
|
||||
PanelPermission.VIEW_BACKUPS,
|
||||
PanelPermission.MANAGE_BACKUPS,
|
||||
PanelPermission.VIEW_REPORTS,
|
||||
PanelPermission.MANAGE_REPORTS,
|
||||
PanelPermission.VIEW_TICKETS,
|
||||
PanelPermission.MANAGE_TICKETS,
|
||||
PanelPermission.VIEW_PLAYER_MANAGEMENT,
|
||||
PanelPermission.MANAGE_PLAYER_MANAGEMENT,
|
||||
PanelPermission.VIEW_PLAYER_STATS,
|
||||
PanelPermission.VIEW_LUCKPERMS,
|
||||
PanelPermission.VIEW_AIRSTRIKE,
|
||||
PanelPermission.MANAGE_AIRSTRIKE,
|
||||
PanelPermission.VIEW_MAINTENANCE,
|
||||
PanelPermission.MANAGE_MAINTENANCE,
|
||||
PanelPermission.VIEW_WHITELIST,
|
||||
PanelPermission.MANAGE_WHITELIST,
|
||||
PanelPermission.VIEW_ANNOUNCEMENTS,
|
||||
PanelPermission.MANAGE_ANNOUNCEMENTS
|
||||
)),
|
||||
VIEWER(EnumSet.of(
|
||||
PanelPermission.ACCESS_PANEL,
|
||||
PanelPermission.VIEW_OVERVIEW,
|
||||
PanelPermission.VIEW_CONSOLE,
|
||||
PanelPermission.VIEW_RESOURCES,
|
||||
PanelPermission.VIEW_PLAYERS,
|
||||
PanelPermission.VIEW_BANS,
|
||||
PanelPermission.VIEW_PLUGINS,
|
||||
PanelPermission.VIEW_THEMES,
|
||||
PanelPermission.VIEW_BACKUPS,
|
||||
PanelPermission.VIEW_REPORTS,
|
||||
PanelPermission.VIEW_TICKETS,
|
||||
PanelPermission.VIEW_PLAYER_MANAGEMENT,
|
||||
PanelPermission.VIEW_PLAYER_STATS,
|
||||
PanelPermission.VIEW_LUCKPERMS,
|
||||
PanelPermission.VIEW_AIRSTRIKE,
|
||||
PanelPermission.VIEW_MAINTENANCE,
|
||||
PanelPermission.VIEW_WHITELIST,
|
||||
PanelPermission.VIEW_ANNOUNCEMENTS
|
||||
));
|
||||
|
||||
private final Set<PanelPermission> permissions;
|
||||
|
||||
UserRole(Set<PanelPermission> permissions) {
|
||||
this.permissions = permissions;
|
||||
}
|
||||
|
||||
public boolean hasPermission(PanelPermission permission) {
|
||||
return permissions.contains(permission);
|
||||
}
|
||||
|
||||
public Set<PanelPermission> defaultPermissions() {
|
||||
return EnumSet.copyOf(permissions);
|
||||
}
|
||||
|
||||
public static UserRole fromString(String raw) {
|
||||
return UserRole.valueOf(raw.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package de.winniepat.minePanel.util;
|
||||
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class ServerSchedulerBridge {
|
||||
|
||||
private final JavaPlugin plugin;
|
||||
private final boolean folia;
|
||||
private final Object globalRegionScheduler;
|
||||
private final Method globalRunMethod;
|
||||
private final Method globalRunDelayedMethod;
|
||||
private final Method globalRunAtFixedRateMethod;
|
||||
private final Object asyncScheduler;
|
||||
private final Method asyncRunNowMethod;
|
||||
|
||||
public ServerSchedulerBridge(JavaPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
|
||||
Object resolvedGlobalScheduler = null;
|
||||
Method resolvedGlobalRun = null;
|
||||
Method resolvedGlobalRunDelayed = null;
|
||||
Method resolvedGlobalRunAtFixedRate = null;
|
||||
|
||||
Object resolvedAsyncScheduler = null;
|
||||
Method resolvedAsyncRunNow = null;
|
||||
|
||||
try {
|
||||
resolvedGlobalScheduler = plugin.getServer().getClass().getMethod("getGlobalRegionScheduler").invoke(plugin.getServer());
|
||||
resolvedGlobalRun = resolvedGlobalScheduler.getClass().getMethod("run", Plugin.class, Consumer.class);
|
||||
resolvedGlobalRunDelayed = resolvedGlobalScheduler.getClass().getMethod("runDelayed", Plugin.class, Consumer.class, long.class);
|
||||
resolvedGlobalRunAtFixedRate = resolvedGlobalScheduler.getClass().getMethod("runAtFixedRate", Plugin.class, Consumer.class, long.class, long.class);
|
||||
|
||||
resolvedAsyncScheduler = plugin.getServer().getClass().getMethod("getAsyncScheduler").invoke(plugin.getServer());
|
||||
resolvedAsyncRunNow = resolvedAsyncScheduler.getClass().getMethod("runNow", Plugin.class, Consumer.class);
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
// Paper/Spigot fallback uses BukkitScheduler APIs.
|
||||
}
|
||||
|
||||
this.globalRegionScheduler = resolvedGlobalScheduler;
|
||||
this.globalRunMethod = resolvedGlobalRun;
|
||||
this.globalRunDelayedMethod = resolvedGlobalRunDelayed;
|
||||
this.globalRunAtFixedRateMethod = resolvedGlobalRunAtFixedRate;
|
||||
this.asyncScheduler = resolvedAsyncScheduler;
|
||||
this.asyncRunNowMethod = resolvedAsyncRunNow;
|
||||
this.folia = this.globalRegionScheduler != null && this.globalRunMethod != null;
|
||||
}
|
||||
|
||||
public boolean isFolia() {
|
||||
return folia;
|
||||
}
|
||||
|
||||
public CancellableTask runRepeatingGlobal(Runnable task, long initialDelayTicks, long periodTicks) {
|
||||
if (!folia) {
|
||||
BukkitTask bukkitTask = plugin.getServer().getScheduler().runTaskTimer(plugin, task, initialDelayTicks, periodTicks);
|
||||
return bukkitTask::cancel;
|
||||
}
|
||||
|
||||
if (globalRunDelayedMethod == null) {
|
||||
try {
|
||||
Object scheduledTask = globalRunAtFixedRateMethod.invoke(
|
||||
globalRegionScheduler,
|
||||
plugin,
|
||||
(Consumer<Object>) ignored -> task.run(),
|
||||
initialDelayTicks,
|
||||
periodTicks
|
||||
);
|
||||
return () -> cancelFoliaTask(scheduledTask);
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("could_not_schedule_global_repeating_task", exception);
|
||||
}
|
||||
}
|
||||
|
||||
long safeInitialDelay = Math.max(0L, initialDelayTicks);
|
||||
long safePeriod = Math.max(1L, periodTicks);
|
||||
AtomicBoolean cancelled = new AtomicBoolean(false);
|
||||
AtomicReference<Object> scheduledTaskRef = new AtomicReference<>();
|
||||
|
||||
class FoliaRepeatingState {
|
||||
void scheduleNext(long delayTicks) {
|
||||
if (cancelled.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Object scheduledTask = globalRunDelayedMethod.invoke(
|
||||
globalRegionScheduler,
|
||||
plugin,
|
||||
(Consumer<Object>) ignored -> {
|
||||
if (cancelled.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.run();
|
||||
scheduleNext(safePeriod);
|
||||
},
|
||||
Math.max(0L, delayTicks)
|
||||
);
|
||||
scheduledTaskRef.set(scheduledTask);
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("could_not_schedule_global_repeating_task", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FoliaRepeatingState repeatingState = new FoliaRepeatingState();
|
||||
// Do not block startup waiting for first tick; on Folia this can deadlock when called during enable.
|
||||
repeatingState.scheduleNext(safeInitialDelay);
|
||||
|
||||
return () -> {
|
||||
cancelled.set(true);
|
||||
cancelFoliaTask(scheduledTaskRef.get());
|
||||
};
|
||||
}
|
||||
|
||||
public void runGlobal(Runnable task) {
|
||||
if (!folia) {
|
||||
plugin.getServer().getScheduler().runTask(plugin, task);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
globalRunMethod.invoke(globalRegionScheduler, plugin, (Consumer<Object>) ignored -> task.run());
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("could_not_schedule_global_task", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void runAsync(Runnable task) {
|
||||
if (!folia || asyncScheduler == null || asyncRunNowMethod == null) {
|
||||
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, task);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
asyncRunNowMethod.invoke(asyncScheduler, plugin, (Consumer<Object>) ignored -> task.run());
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("could_not_schedule_async_task", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public <T> T callGlobal(Callable<T> task, long timeout, TimeUnit timeUnit)
|
||||
throws InterruptedException, ExecutionException, TimeoutException {
|
||||
CompletableFuture<T> future = new CompletableFuture<>();
|
||||
runGlobal(() -> {
|
||||
try {
|
||||
future.complete(task.call());
|
||||
} catch (Throwable throwable) {
|
||||
future.completeExceptionally(throwable);
|
||||
}
|
||||
});
|
||||
return future.get(timeout, timeUnit);
|
||||
}
|
||||
|
||||
private void cancelFoliaTask(Object task) {
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Method cancelMethod = task.getClass().getMethod("cancel");
|
||||
cancelMethod.invoke(task);
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
// Best-effort cancellation.
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface CancellableTask {
|
||||
void cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.winniepat.minePanel.web;
|
||||
|
||||
import de.winniepat.minePanel.persistence.UserRepository;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.*;
|
||||
|
||||
public final class BootstrapService {
|
||||
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final String bootstrapToken;
|
||||
|
||||
public BootstrapService(UserRepository userRepository, int tokenLength) {
|
||||
this.userRepository = userRepository;
|
||||
this.bootstrapToken = generateToken(Math.max(16, tokenLength));
|
||||
}
|
||||
|
||||
public boolean needsBootstrap() {
|
||||
return userRepository.countUsers() == 0;
|
||||
}
|
||||
|
||||
public Optional<String> getBootstrapToken() {
|
||||
if (!needsBootstrap()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(bootstrapToken);
|
||||
}
|
||||
|
||||
public boolean verifyToken(String token) {
|
||||
return needsBootstrap() && token != null && bootstrapToken.equals(token);
|
||||
}
|
||||
|
||||
private String generateToken(int length) {
|
||||
byte[] bytes = new byte[length];
|
||||
SECURE_RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).substring(0, length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.winniepat.minePanel.web;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class ResourceLoader {
|
||||
|
||||
private ResourceLoader() {
|
||||
}
|
||||
|
||||
public static String loadUtf8Text(String resourcePath) {
|
||||
try (InputStream inputStream = ResourceLoader.class.getResourceAsStream(resourcePath)) {
|
||||
if (inputStream == null) {
|
||||
throw new IllegalStateException("Resource not found: " + resourcePath);
|
||||
}
|
||||
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Could not read resource: " + resourcePath, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package de.winniepat.minePanel.web;
|
||||
|
||||
import de.winniepat.minePanel.MinePanel;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class WebAssetService {
|
||||
|
||||
private static final List<String> BUNDLED_WEB_FILES = List.of(
|
||||
"dashboard-account.html",
|
||||
"dashboard-announcements.html",
|
||||
"dashboard-bans.html",
|
||||
"dashboard-console.html",
|
||||
"dashboard-discord-webhook.html",
|
||||
"dashboard-extension-config.html",
|
||||
"dashboard-extensions.html",
|
||||
"dashboard-maintenance.html",
|
||||
"dashboard-overview.html",
|
||||
"dashboard-players.html",
|
||||
"dashboard-plugins.html",
|
||||
"dashboard-reports.html",
|
||||
"dashboard-resources.html",
|
||||
"dashboard-tickets.html",
|
||||
"dashboard-world-backups.html",
|
||||
"dashboard-whitelist.html",
|
||||
"dashboard-themes.html",
|
||||
"dashboard-users.html",
|
||||
"login.html",
|
||||
"panel.css",
|
||||
"setup.html",
|
||||
"theme.js"
|
||||
);
|
||||
|
||||
private static final String LEGACY_EXTENSIONS_DOWNLOAD_MARKER = "href=\"${downloadUrl}\"";
|
||||
private static final String CURRENT_EXTENSIONS_INSTALL_MARKER = "data-install-extension";
|
||||
private static final long VERSION_CACHE_TTL_MILLIS = TimeUnit.SECONDS.toMillis(2);
|
||||
|
||||
private final MinePanel plugin;
|
||||
private final Path webDirectory;
|
||||
private final Map<String, CachedTextAsset> cachedTextAssets = new ConcurrentHashMap<>();
|
||||
private volatile long cachedVersion = 0L;
|
||||
private volatile long cachedVersionExpiresAt = 0L;
|
||||
|
||||
public WebAssetService(MinePanel plugin, Path webDirectory) {
|
||||
this.plugin = plugin;
|
||||
this.webDirectory = webDirectory;
|
||||
}
|
||||
|
||||
public void ensureSeeded() {
|
||||
try {
|
||||
Files.createDirectories(webDirectory);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Could not create web directory: " + webDirectory, exception);
|
||||
}
|
||||
|
||||
for (String fileName : BUNDLED_WEB_FILES) {
|
||||
Path target = webDirectory.resolve(fileName).normalize();
|
||||
if (!target.startsWith(webDirectory)) {
|
||||
continue;
|
||||
}
|
||||
if (Files.exists(target)) {
|
||||
migrateLegacyAssets(fileName, target);
|
||||
continue;
|
||||
}
|
||||
plugin.saveResource("web/" + fileName, false);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateLegacyAssets(String fileName, Path target) {
|
||||
if (!"dashboard-extensions.html".equals(fileName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
String current = Files.readString(target, StandardCharsets.UTF_8);
|
||||
if (current.contains(CURRENT_EXTENSIONS_INSTALL_MARKER)) {
|
||||
return;
|
||||
}
|
||||
if (!current.contains(LEGACY_EXTENSIONS_DOWNLOAD_MARKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String bundled = ResourceLoader.loadUtf8Text("/web/" + fileName);
|
||||
Files.writeString(target, bundled, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
|
||||
plugin.getLogger().info("Updated legacy web template: " + fileName);
|
||||
} catch (Exception exception) {
|
||||
plugin.getLogger().warning("Could not migrate legacy web template " + fileName + ": " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public String readText(String fileName) {
|
||||
Path target = resolveFile(fileName);
|
||||
try {
|
||||
long lastModified = Files.exists(target) ? Files.getLastModifiedTime(target).toMillis() : 0L;
|
||||
CachedTextAsset cached = cachedTextAssets.get(fileName);
|
||||
if (cached != null && cached.lastModifiedMillis() == lastModified) {
|
||||
return cached.content();
|
||||
}
|
||||
|
||||
String content = Files.readString(target, StandardCharsets.UTF_8);
|
||||
cachedTextAssets.put(fileName, new CachedTextAsset(lastModified, content));
|
||||
return content;
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Could not read web asset: " + fileName, exception);
|
||||
}
|
||||
}
|
||||
|
||||
public long currentVersion() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now < cachedVersionExpiresAt) {
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
long newest = 0L;
|
||||
for (String fileName : BUNDLED_WEB_FILES) {
|
||||
Path target = resolveFile(fileName);
|
||||
if (!Files.exists(target)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
newest = Math.max(newest, Files.getLastModifiedTime(target).toMillis());
|
||||
} catch (IOException ignored) {
|
||||
// Ignore broken files so the rest of the panel can continue serving assets.
|
||||
}
|
||||
}
|
||||
cachedVersion = newest;
|
||||
cachedVersionExpiresAt = now + VERSION_CACHE_TTL_MILLIS;
|
||||
return newest;
|
||||
}
|
||||
|
||||
public long lastModifiedMillis(String fileName) {
|
||||
Path target = resolveFile(fileName);
|
||||
try {
|
||||
if (!Files.exists(target)) {
|
||||
return 0L;
|
||||
}
|
||||
return Files.getLastModifiedTime(target).toMillis();
|
||||
} catch (IOException exception) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveFile(String fileName) {
|
||||
Path target = webDirectory.resolve(fileName).normalize();
|
||||
if (!target.startsWith(webDirectory)) {
|
||||
throw new IllegalArgumentException("Invalid web asset path: " + fileName);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
public Path webDirectory() {
|
||||
return webDirectory;
|
||||
}
|
||||
|
||||
private record CachedTextAsset(long lastModifiedMillis, String content) {
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
web:
|
||||
host: 127.0.0.1
|
||||
port: 8080
|
||||
sessionTtlMinutes: 120
|
||||
|
||||
security:
|
||||
bootstrapTokenLength: 32
|
||||
|
||||
integrations:
|
||||
oauth:
|
||||
stateTtlMinutes: 10
|
||||
google:
|
||||
enabled: false
|
||||
clientId: ""
|
||||
clientSecret: ""
|
||||
redirectUri: "http://127.0.0.1:8080/api/oauth/google/callback"
|
||||
discord:
|
||||
enabled: false
|
||||
clientId: ""
|
||||
clientSecret: ""
|
||||
redirectUri: "http://127.0.0.1:8080/api/oauth/discord/callback"
|
||||
github:
|
||||
token: ""
|
||||
releaseCacheSeconds: 300
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: MinePanel
|
||||
version: '${version}'
|
||||
main: de.winniepat.minePanel.MinePanel
|
||||
api-version: '1.21'
|
||||
authors: [ WinniePatGG ]
|
||||
website: https://winniepat.de
|
||||
description: MinePanel with secure auth, user roles, and log history.
|
||||
folia-supported: true
|
||||
|
||||
|
||||
permissions:
|
||||
minepanel.report:
|
||||
description: Allows players to create reports via /report.
|
||||
default: true
|
||||
minepanel.ticket:
|
||||
description: Allows players to create support tickets via /ticket.
|
||||
default: true
|
||||
minepanel.chatfilter.bypass:
|
||||
description: Bypasses MinePanel bad-word chat filter auto moderation.
|
||||
default: op
|
||||
@@ -0,0 +1,240 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Account</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
<style>
|
||||
.oauth-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.oauth-provider {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.oauth-provider h3 {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.oauth-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.oauth-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.oauth-status {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
<a class="side-link active" href="/dashboard/account">Account</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Account Settings</h1>
|
||||
<section class="card spaced-top">
|
||||
<h2>Linked OAuth Accounts</h2>
|
||||
<p>Link Google or Discord for one-click login on the login page.</p>
|
||||
<p id="status" class="action-status"></p>
|
||||
<div id="providers" class="oauth-grid"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
function setStatus(message, isError) {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = message || '';
|
||||
status.className = isError ? 'action-status error-text' : 'action-status success-text';
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
function providerCard(provider) {
|
||||
const linked = !!provider.linked;
|
||||
const enabled = !!provider.enabled;
|
||||
const configured = !!provider.configured;
|
||||
const displayName = (provider.displayName || '').trim();
|
||||
const email = (provider.email || '').trim();
|
||||
const subtitle = linked
|
||||
? [displayName || 'Linked account', email].filter(Boolean).join(' - ')
|
||||
: 'Not linked';
|
||||
|
||||
return `
|
||||
<article class="oauth-provider">
|
||||
<h3>${provider.label}</h3>
|
||||
<div class="oauth-meta">${subtitle}</div>
|
||||
<div class="oauth-actions">
|
||||
<button data-link="${provider.provider}" ${!enabled || !configured ? 'disabled' : ''}>Link</button>
|
||||
<button class="secondary" data-unlink="${provider.provider}" ${!linked ? 'disabled' : ''}>Unlink</button>
|
||||
</div>
|
||||
<div class="oauth-status">${!enabled ? 'Disabled in config' : (!configured ? 'Missing OAuth credentials' : (linked ? 'Linked' : 'Ready to link'))}</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
const data = await api('/api/account/links');
|
||||
const container = document.getElementById('providers');
|
||||
const providers = Array.isArray(data.providers) ? data.providers : [];
|
||||
|
||||
container.innerHTML = providers.map(providerCard).join('');
|
||||
|
||||
container.querySelectorAll('button[data-link]').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const provider = button.getAttribute('data-link');
|
||||
try {
|
||||
const start = await api(`/api/oauth/${provider}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'link' })
|
||||
});
|
||||
window.location.href = start.authUrl;
|
||||
} catch (error) {
|
||||
setStatus(`Could not start ${provider} linking: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('button[data-unlink]').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const provider = button.getAttribute('data-unlink');
|
||||
if (!confirm(`Unlink ${provider} from your panel account?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/api/oauth/${provider}/unlink`, { method: 'POST' });
|
||||
setStatus(`${provider} unlinked.`, false);
|
||||
await loadProviders();
|
||||
} catch (error) {
|
||||
setStatus(`Could not unlink ${provider}: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showOAuthQueryState() {
|
||||
const url = new URL(window.location.href);
|
||||
const oauth = (url.searchParams.get('oauth') || '').trim();
|
||||
if (!oauth) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (oauth === 'linked') {
|
||||
setStatus('OAuth account linked successfully.', false);
|
||||
} else {
|
||||
setStatus(`OAuth status: ${oauth}`, true);
|
||||
}
|
||||
|
||||
url.searchParams.delete('oauth');
|
||||
window.history.replaceState({}, document.title, url.pathname + (url.search ? `?${url.searchParams.toString()}` : ''));
|
||||
}
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
try {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
setStatus(`Logout failed: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await loadMe();
|
||||
showOAuthQueryState();
|
||||
await loadProviders();
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Announcements</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
<style>
|
||||
.announce-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.announce-stat {
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.announce-stat-title {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.announce-stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.announce-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.announce-item {
|
||||
border: 1px solid var(--table-row-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: var(--panel-bg);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.announce-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.announce-meta {
|
||||
color: var(--muted-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.announce-message {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.announce-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.announce-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.announce-message-input {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
padding: 10px 11px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
min-height: 90px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.announce-message-input:focus {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.announce-empty {
|
||||
color: var(--muted-text);
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.announce-placeholders {
|
||||
margin-top: 8px;
|
||||
color: var(--muted-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
<a class="side-link" href="/dashboard/extensions">Extensions</a>
|
||||
<a class="side-link" href="/dashboard/extension-config">Extension Config</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Announcements</h1>
|
||||
<p>Broadcast rotating server announcements to all online players.</p>
|
||||
|
||||
<section class="card">
|
||||
<h2>Status</h2>
|
||||
<div class="announce-grid">
|
||||
<div class="announce-stat">
|
||||
<div class="announce-stat-title">State</div>
|
||||
<div id="stateValue" class="announce-stat-value">Disabled</div>
|
||||
</div>
|
||||
<div class="announce-stat">
|
||||
<div class="announce-stat-title">Interval</div>
|
||||
<div id="intervalValue" class="announce-stat-value">300s</div>
|
||||
</div>
|
||||
<div class="announce-stat">
|
||||
<div class="announce-stat-title">Next Broadcast</div>
|
||||
<div id="nextRunValue" class="announce-stat-value">-</div>
|
||||
</div>
|
||||
<div class="announce-stat">
|
||||
<div class="announce-stat-title">Messages</div>
|
||||
<div id="countValue" class="announce-stat-value">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Configuration</h2>
|
||||
<label class="checkbox-line"><input id="enabledInput" type="checkbox"> Enable automatic announcements</label>
|
||||
<label for="intervalInput">Broadcast interval (seconds)</label>
|
||||
<input id="intervalInput" type="number" min="10" max="86400" value="300">
|
||||
<div class="announce-toolbar">
|
||||
<button id="saveConfigBtn">Save Configuration</button>
|
||||
<button id="sendNowBtn" class="secondary">Send Next Message Now</button>
|
||||
<button id="refreshBtn" class="secondary">Refresh</button>
|
||||
</div>
|
||||
<p id="status" class="action-status"></p>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Create Message</h2>
|
||||
<textarea id="messageInput" class="announce-message-input" maxlength="220" placeholder="Welcome to the server! Use /ticket if you need support."></textarea>
|
||||
<p class="announce-placeholders">
|
||||
Add your own prefix directly in the message text (for example <code>[Network]</code> or <code><gold>[MinePanel]</gold></code>).<br>
|
||||
Placeholders: <code>%player%</code>, <code>%time%</code>, <code>%date%</code>, <code>%datetime%</code>, <code>%online%</code>, <code>%max_players%</code>, <code>%world%</code>, <code>%server%</code>, <code>%tps%</code>.
|
||||
</p>
|
||||
<div class="announce-toolbar">
|
||||
<button id="createBtn">Add Message</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Messages</h2>
|
||||
<div id="messageList" class="announce-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const nextExpanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', nextExpanded);
|
||||
toggle.classList.toggle('expanded', nextExpanded);
|
||||
savedState[category] = nextExpanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.error || 'Request failed');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function setStatus(message, isError) {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = message || '';
|
||||
status.className = isError ? 'action-status error-text' : 'action-status success-text';
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
const millis = Number(timestamp || 0);
|
||||
if (!Number.isFinite(millis) || millis <= 0) {
|
||||
return '-';
|
||||
}
|
||||
return new Date(millis).toLocaleString();
|
||||
}
|
||||
|
||||
function applyState(state) {
|
||||
const enabled = state && state.enabled === true;
|
||||
const interval = Number(state && state.intervalSeconds ? state.intervalSeconds : 300);
|
||||
const messages = Array.isArray(state && state.messages) ? state.messages : [];
|
||||
|
||||
document.getElementById('stateValue').textContent = enabled ? 'Enabled' : 'Disabled';
|
||||
document.getElementById('intervalValue').textContent = `${interval}s`;
|
||||
document.getElementById('nextRunValue').textContent = formatTimestamp(state ? state.nextRunAt : 0);
|
||||
document.getElementById('countValue').textContent = String(messages.length);
|
||||
|
||||
document.getElementById('enabledInput').checked = enabled;
|
||||
document.getElementById('intervalInput').value = String(interval);
|
||||
|
||||
renderMessages(messages);
|
||||
}
|
||||
|
||||
function renderMessages(messages) {
|
||||
const list = document.getElementById('messageList');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'announce-empty';
|
||||
empty.textContent = 'No announcements configured yet.';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of messages) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'announce-item';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'announce-item-header';
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'announce-meta';
|
||||
meta.textContent = `#${entry.id} · Updated ${formatTimestamp(entry.updatedAt)}`;
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'announce-toolbar';
|
||||
|
||||
const toggleLabel = document.createElement('label');
|
||||
toggleLabel.className = 'announce-toggle';
|
||||
toggleLabel.innerHTML = `<input type="checkbox" ${entry.enabled ? 'checked' : ''}> Enabled`;
|
||||
toggleLabel.querySelector('input').addEventListener('change', async (event) => {
|
||||
try {
|
||||
await api(`/api/extensions/announcements/messages/${entry.id}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ enabled: !!event.target.checked })
|
||||
});
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
setStatus(`Could not update message #${entry.id}: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.type = 'button';
|
||||
deleteButton.className = 'danger';
|
||||
deleteButton.textContent = 'Delete';
|
||||
deleteButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await api(`/api/extensions/announcements/messages/${entry.id}/delete`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
setStatus(`Deleted announcement #${entry.id}`, false);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
setStatus(`Could not delete message #${entry.id}: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
controls.appendChild(toggleLabel);
|
||||
controls.appendChild(deleteButton);
|
||||
header.appendChild(meta);
|
||||
header.appendChild(controls);
|
||||
|
||||
const message = document.createElement('div');
|
||||
message.className = 'announce-message';
|
||||
message.textContent = entry.message || '';
|
||||
|
||||
item.appendChild(header);
|
||||
item.appendChild(message);
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const state = await api('/api/extensions/announcements', { credentials: 'same-origin', cache: 'no-store' });
|
||||
applyState(state);
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const enabled = document.getElementById('enabledInput').checked;
|
||||
const interval = Number(document.getElementById('intervalInput').value || 300);
|
||||
|
||||
try {
|
||||
await api('/api/extensions/announcements/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ enabled, intervalSeconds: interval })
|
||||
});
|
||||
setStatus('Configuration saved', false);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
setStatus(`Could not save configuration: ${error.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function createMessage() {
|
||||
const input = document.getElementById('messageInput');
|
||||
const message = (input.value || '').trim();
|
||||
if (!message) {
|
||||
setStatus('Enter a message first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api('/api/extensions/announcements/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ message })
|
||||
});
|
||||
input.value = '';
|
||||
setStatus('Announcement added', false);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
setStatus(`Could not create announcement: ${error.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendNow() {
|
||||
try {
|
||||
await api('/api/extensions/announcements/send-now', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
setStatus('Sent next announcement', false);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
setStatus(`Could not send announcement: ${error.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api('/api/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
document.getElementById('saveConfigBtn').addEventListener('click', saveConfig);
|
||||
document.getElementById('createBtn').addEventListener('click', createMessage);
|
||||
document.getElementById('sendNowBtn').addEventListener('click', sendNow);
|
||||
document.getElementById('refreshBtn').addEventListener('click', refresh);
|
||||
document.getElementById('logout').addEventListener('click', logout);
|
||||
|
||||
(async function boot() {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await loadMe();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
if (error && error.status === 401) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
setStatus(`Could not load announcements page: ${error.message}`, true);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Bans</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link active" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Bans</h1>
|
||||
<p>Current bans: <span id="banCount">0</span></p>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Ban List</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Status</th>
|
||||
<th>Expires</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bans"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="banStatus" class="action-status"></p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
function setStatus(message, isError) {
|
||||
const status = document.getElementById('banStatus');
|
||||
status.textContent = message || '';
|
||||
status.className = isError ? 'action-status error-text' : 'action-status success-text';
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
async function loadBans() {
|
||||
const players = await api('/api/players');
|
||||
const allPlayers = Array.isArray(players.allPlayers) ? players.allPlayers : [];
|
||||
const bans = allPlayers.filter(player => player.banned);
|
||||
|
||||
document.getElementById('banCount').textContent = String(bans.length);
|
||||
const tbody = document.getElementById('bans');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (bans.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 4;
|
||||
cell.textContent = 'No active bans';
|
||||
row.appendChild(cell);
|
||||
tbody.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
bans.forEach(player => {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
const playerCell = document.createElement('td');
|
||||
playerCell.textContent = player.username;
|
||||
row.appendChild(playerCell);
|
||||
|
||||
const statusCell = document.createElement('td');
|
||||
statusCell.textContent = player.online ? 'Online' : 'Offline';
|
||||
row.appendChild(statusCell);
|
||||
|
||||
const expiresCell = document.createElement('td');
|
||||
expiresCell.textContent = player.banExpiresAt ? new Date(player.banExpiresAt).toLocaleString() : 'Permanent';
|
||||
row.appendChild(expiresCell);
|
||||
|
||||
const actionCell = document.createElement('td');
|
||||
const button = document.createElement('button');
|
||||
button.className = 'secondary';
|
||||
button.textContent = 'Unban';
|
||||
button.addEventListener('click', async () => {
|
||||
try {
|
||||
await api('/api/players/unban', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: player.username, uuid: player.uuid })
|
||||
});
|
||||
setStatus(`Unbanned ${player.username}`, false);
|
||||
await loadBans();
|
||||
} catch (error) {
|
||||
setStatus(`Failed to unban ${player.username}: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
actionCell.appendChild(button);
|
||||
row.appendChild(actionCell);
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await Promise.all([loadMe(), loadBans()]);
|
||||
setInterval(() => {
|
||||
loadBans().catch(() => {});
|
||||
}, 5000);
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Console</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link active" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Console</h1>
|
||||
<div class="toolbar">
|
||||
<button id="refresh" class="secondary">Refresh Logs</button>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Panel Logs + Chat + Commands</h2>
|
||||
<pre id="panelLogs" class="log-rectangle"></pre>
|
||||
<div class="console-send-row">
|
||||
<input id="consoleInput" placeholder="Type a console command (example: say hello world)">
|
||||
<button id="sendConsole">Send</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let latestPanelLogId = 0;
|
||||
|
||||
async function api(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
const data = await api('/api/logs?limit=250');
|
||||
const panelLogsElement = document.getElementById('panelLogs');
|
||||
|
||||
panelLogsElement.textContent = [...data.panelLogs]
|
||||
.reverse()
|
||||
.map(x => `[${new Date(x.createdAt).toISOString()}] [${x.kind}] [${x.source}] ${x.message}`)
|
||||
.join('\n');
|
||||
|
||||
if (data.panelLogs.length > 0) {
|
||||
latestPanelLogId = Math.max(latestPanelLogId, ...data.panelLogs.map(x => x.id));
|
||||
}
|
||||
|
||||
panelLogsElement.scrollTop = panelLogsElement.scrollHeight;
|
||||
}
|
||||
|
||||
async function watchForNewLogs() {
|
||||
const data = await api('/api/logs/latest');
|
||||
if (data.latestId > latestPanelLogId) {
|
||||
await loadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
async function sendConsoleMessage() {
|
||||
const input = document.getElementById('consoleInput');
|
||||
const message = input.value.trim();
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api('/api/console/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message })
|
||||
});
|
||||
|
||||
input.value = '';
|
||||
await loadLogs();
|
||||
}
|
||||
|
||||
document.getElementById('refresh').addEventListener('click', async () => {
|
||||
await loadLogs();
|
||||
});
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
document.getElementById('sendConsole').addEventListener('click', async () => {
|
||||
await sendConsoleMessage();
|
||||
});
|
||||
|
||||
document.getElementById('consoleInput').addEventListener('keydown', async event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
await sendConsoleMessage();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await loadMe();
|
||||
await loadLogs();
|
||||
setInterval(() => {
|
||||
watchForNewLogs().catch(() => {});
|
||||
}, 1000);
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Discord Webhook</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link active" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Discord Webhook</h1>
|
||||
<p>Configure what MinePanel sends to your Discord channel.</p>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Webhook Settings</h2>
|
||||
<div class="integration-form">
|
||||
<label class="label checkbox-line">
|
||||
<input id="enabled" type="checkbox">
|
||||
Enable Discord webhook logging
|
||||
</label>
|
||||
|
||||
<div class="label">Webhook URL</div>
|
||||
<input id="webhookUrl" placeholder="https://discord.com/api/webhooks/...">
|
||||
|
||||
<div class="label">Bot Name</div>
|
||||
<input id="botName" placeholder="MinePanel">
|
||||
|
||||
<label class="label checkbox-line">
|
||||
<input id="useEmbed" type="checkbox">
|
||||
Send as embed
|
||||
</label>
|
||||
|
||||
<div class="label">Message Template</div>
|
||||
<input id="messageTemplate" placeholder="[{timestamp}] [{kind}] [{source}] {message}">
|
||||
|
||||
<div class="label">Embed Title Template</div>
|
||||
<input id="embedTitleTemplate" placeholder="MinePanel {kind}">
|
||||
|
||||
<div class="label">Event Filters</div>
|
||||
<div class="toggle-grid">
|
||||
<label class="checkbox-line"><input id="logChat" type="checkbox">Chat</label>
|
||||
<label class="checkbox-line"><input id="logCommands" type="checkbox">Commands</label>
|
||||
<label class="checkbox-line"><input id="logAuth" type="checkbox">Auth</label>
|
||||
<label class="checkbox-line"><input id="logAudit" type="checkbox">Audit</label>
|
||||
<label class="checkbox-line"><input id="logSecurity" type="checkbox">Security (Alt Alerts)</label>
|
||||
<label class="checkbox-line"><input id="logConsoleResponse" type="checkbox">Console Response</label>
|
||||
<label class="checkbox-line"><input id="logSystem" type="checkbox">System</label>
|
||||
</div>
|
||||
|
||||
<button id="saveWebhook">Save Webhook Settings</button>
|
||||
<p id="webhookStatus" class="action-status"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Template Variables</h2>
|
||||
<p class="template-help">Use <code>{timestamp}</code>, <code>{kind}</code>, <code>{source}</code>, and <code>{message}</code> in your templates.</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
function setStatus(message, isError) {
|
||||
const status = document.getElementById('webhookStatus');
|
||||
status.textContent = message || '';
|
||||
status.className = isError ? 'action-status error-text' : 'action-status success-text';
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
function applyConfig(config) {
|
||||
document.getElementById('enabled').checked = !!config.enabled;
|
||||
document.getElementById('webhookUrl').value = config.webhookUrl || '';
|
||||
document.getElementById('useEmbed').checked = !!config.useEmbed;
|
||||
document.getElementById('botName').value = config.botName || '';
|
||||
document.getElementById('messageTemplate').value = config.messageTemplate || '';
|
||||
document.getElementById('embedTitleTemplate').value = config.embedTitleTemplate || '';
|
||||
document.getElementById('logChat').checked = !!config.logChat;
|
||||
document.getElementById('logCommands').checked = !!config.logCommands;
|
||||
document.getElementById('logAuth').checked = !!config.logAuth;
|
||||
document.getElementById('logAudit').checked = !!config.logAudit;
|
||||
document.getElementById('logSecurity').checked = !!config.logSecurity;
|
||||
document.getElementById('logConsoleResponse').checked = !!config.logConsoleResponse;
|
||||
document.getElementById('logSystem').checked = !!config.logSystem;
|
||||
}
|
||||
|
||||
function collectConfig() {
|
||||
return {
|
||||
enabled: document.getElementById('enabled').checked,
|
||||
webhookUrl: document.getElementById('webhookUrl').value,
|
||||
useEmbed: document.getElementById('useEmbed').checked,
|
||||
botName: document.getElementById('botName').value,
|
||||
messageTemplate: document.getElementById('messageTemplate').value,
|
||||
embedTitleTemplate: document.getElementById('embedTitleTemplate').value,
|
||||
logChat: document.getElementById('logChat').checked,
|
||||
logCommands: document.getElementById('logCommands').checked,
|
||||
logAuth: document.getElementById('logAuth').checked,
|
||||
logAudit: document.getElementById('logAudit').checked,
|
||||
logSecurity: document.getElementById('logSecurity').checked,
|
||||
logConsoleResponse: document.getElementById('logConsoleResponse').checked,
|
||||
logSystem: document.getElementById('logSystem').checked
|
||||
};
|
||||
}
|
||||
|
||||
async function loadWebhookConfig() {
|
||||
const config = await api('/api/integrations/discord-webhook');
|
||||
applyConfig(config);
|
||||
}
|
||||
|
||||
async function saveWebhookConfig() {
|
||||
const payload = collectConfig();
|
||||
const response = await api('/api/integrations/discord-webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
applyConfig(response.config);
|
||||
}
|
||||
|
||||
document.getElementById('saveWebhook').addEventListener('click', async () => {
|
||||
try {
|
||||
await saveWebhookConfig();
|
||||
setStatus('Webhook settings saved', false);
|
||||
} catch (error) {
|
||||
setStatus(`Save failed: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await Promise.all([loadMe(), loadWebhookConfig()]);
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Extension Config</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
<style>
|
||||
.config-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(220px, 280px) 1fr;
|
||||
}
|
||||
|
||||
.config-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
border: 1px solid var(--table-row-border);
|
||||
background: var(--panel-bg);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.config-item.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.config-item small {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.word-list {
|
||||
min-height: 130px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
padding: 10px 11px;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.word-list:focus {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.kv-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kv-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: 1.1fr 1.3fr 130px auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kv-row button {
|
||||
width: auto;
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
<a class="side-link" href="/dashboard/extensions">Extensions</a>
|
||||
<a class="side-link active" href="/dashboard/extension-config">Extension Config</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Extension Config</h1>
|
||||
<p>Configure extension-specific settings from the panel using form fields.</p>
|
||||
|
||||
<section class="card">
|
||||
<div class="config-grid">
|
||||
<div>
|
||||
<h2>Installed Extensions</h2>
|
||||
<div id="extensionList" class="config-list"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="editorTitle">Settings</h2>
|
||||
<div id="configForm" class="config-form"></div>
|
||||
<div class="toolbar">
|
||||
<button id="addFieldBtn" class="secondary" style="display:none;">Add Field</button>
|
||||
<button id="saveBtn">Save Settings</button>
|
||||
<button id="reloadBtn" class="secondary">Reload</button>
|
||||
</div>
|
||||
<p id="status" class="action-status"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expandedNow = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expandedNow);
|
||||
toggle.classList.toggle('expanded', expandedNow);
|
||||
savedState[category] = expandedNow;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.error || 'Request failed');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function setStatus(message, isError) {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = message || '';
|
||||
status.className = isError ? 'action-status error-text' : 'action-status success-text';
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
let extensions = [];
|
||||
let selectedExtensionId = '';
|
||||
let selectedSettings = {};
|
||||
|
||||
function defaultPlayerManagementSettings() {
|
||||
return {
|
||||
badWordFilter: {
|
||||
enabled: false,
|
||||
words: [],
|
||||
autoMuteMinutes: 15,
|
||||
autoMuteReason: 'Inappropriate language',
|
||||
cancelMessage: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderExtensionList() {
|
||||
const list = document.getElementById('extensionList');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!Array.isArray(extensions) || extensions.length === 0) {
|
||||
const noData = document.createElement('div');
|
||||
noData.textContent = 'No installed extensions found.';
|
||||
list.appendChild(noData);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extension of extensions) {
|
||||
const id = extension.id || '';
|
||||
const displayName = extension.displayName || id;
|
||||
const source = extension.source || 'unknown';
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'config-item' + (id === selectedExtensionId ? ' active' : '');
|
||||
button.innerHTML = `<strong>${displayName}</strong><small>${id} - ${source}</small>`;
|
||||
button.addEventListener('click', () => selectExtension(id));
|
||||
list.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
function findExtension(extensionId) {
|
||||
return extensions.find(x => (x.id || '') === extensionId) || null;
|
||||
}
|
||||
|
||||
function clearConfigForm(message) {
|
||||
const form = document.getElementById('configForm');
|
||||
form.innerHTML = '';
|
||||
const placeholder = document.createElement('p');
|
||||
placeholder.className = 'hint';
|
||||
placeholder.textContent = message || 'Select an extension to configure.';
|
||||
form.appendChild(placeholder);
|
||||
}
|
||||
|
||||
function renderPlayerManagementForm(settings) {
|
||||
const merged = {
|
||||
...defaultPlayerManagementSettings(),
|
||||
...(settings && typeof settings === 'object' ? settings : {})
|
||||
};
|
||||
const filter = {
|
||||
...defaultPlayerManagementSettings().badWordFilter,
|
||||
...(merged.badWordFilter && typeof merged.badWordFilter === 'object' ? merged.badWordFilter : {})
|
||||
};
|
||||
|
||||
const words = Array.isArray(filter.words) ? filter.words.join('\n') : '';
|
||||
const form = document.getElementById('configForm');
|
||||
form.innerHTML = `
|
||||
<label class="checkbox-line"><input id="pmEnabled" type="checkbox"> Enable bad-word filter</label>
|
||||
<div>
|
||||
<label class="field-label" for="pmWords">Blocked words (one per line)</label>
|
||||
<textarea id="pmWords" class="word-list" placeholder="word1\nword2"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="pmMinutes">Auto mute duration (minutes, 0 = permanent)</label>
|
||||
<input id="pmMinutes" type="number" min="0" max="43200">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="pmReason">Auto mute reason</label>
|
||||
<input id="pmReason" type="text" maxlength="180">
|
||||
</div>
|
||||
<label class="checkbox-line"><input id="pmCancel" type="checkbox"> Cancel blocked message</label>
|
||||
`;
|
||||
|
||||
document.getElementById('pmEnabled').checked = !!filter.enabled;
|
||||
document.getElementById('pmWords').value = words;
|
||||
document.getElementById('pmMinutes').value = Number.isFinite(Number(filter.autoMuteMinutes)) ? Number(filter.autoMuteMinutes) : 15;
|
||||
document.getElementById('pmReason').value = filter.autoMuteReason || 'Inappropriate language';
|
||||
document.getElementById('pmCancel').checked = filter.cancelMessage !== false;
|
||||
}
|
||||
|
||||
function addGenericFieldRow(key, value, type) {
|
||||
const list = document.getElementById('kvList');
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'kv-row';
|
||||
row.innerHTML = `
|
||||
<input class="kv-key" type="text" placeholder="setting.key" value="${escapeHtmlAttribute(key || '')}">
|
||||
<input class="kv-value" type="text" placeholder="value" value="${escapeHtmlAttribute(value == null ? '' : String(value))}">
|
||||
<select class="kv-type">
|
||||
<option value="string">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">True/False</option>
|
||||
</select>
|
||||
<button class="secondary" type="button">Remove</button>
|
||||
`;
|
||||
|
||||
row.querySelector('.kv-type').value = type || 'string';
|
||||
row.querySelector('button').addEventListener('click', () => row.remove());
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
function renderGenericForm(settings) {
|
||||
const form = document.getElementById('configForm');
|
||||
form.innerHTML = `
|
||||
<p class="hint">This extension does not have dedicated fields yet. Use key/value fields.</p>
|
||||
<div id="kvList" class="kv-list"></div>
|
||||
`;
|
||||
|
||||
const source = settings && typeof settings === 'object' ? settings : {};
|
||||
const entries = Object.entries(source);
|
||||
if (entries.length === 0) {
|
||||
addGenericFieldRow('', '', 'string');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
if (typeof value === 'number') {
|
||||
addGenericFieldRow(key, value, 'number');
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
addGenericFieldRow(key, value ? 'true' : 'false', 'boolean');
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
addGenericFieldRow(key, value, 'string');
|
||||
continue;
|
||||
}
|
||||
addGenericFieldRow(key, JSON.stringify(value), 'string');
|
||||
}
|
||||
}
|
||||
|
||||
function renderSettingsForm(extensionId, settings) {
|
||||
const addFieldBtn = document.getElementById('addFieldBtn');
|
||||
if (extensionId === 'player-management') {
|
||||
addFieldBtn.style.display = 'none';
|
||||
renderPlayerManagementForm(settings);
|
||||
return;
|
||||
}
|
||||
|
||||
addFieldBtn.style.display = '';
|
||||
renderGenericForm(settings);
|
||||
}
|
||||
|
||||
async function selectExtension(extensionId) {
|
||||
selectedExtensionId = extensionId || '';
|
||||
renderExtensionList();
|
||||
|
||||
const editorTitle = document.getElementById('editorTitle');
|
||||
const extension = findExtension(selectedExtensionId);
|
||||
if (!extension) {
|
||||
editorTitle.textContent = 'Settings';
|
||||
selectedSettings = {};
|
||||
clearConfigForm('Select an extension to configure.');
|
||||
return;
|
||||
}
|
||||
|
||||
editorTitle.textContent = `${extension.displayName || extension.id} Settings`;
|
||||
|
||||
const payload = await api(`/api/extensions/config/${encodeURIComponent(selectedExtensionId)}`);
|
||||
selectedSettings = payload && payload.settings && typeof payload.settings === 'object' ? payload.settings : {};
|
||||
renderSettingsForm(selectedExtensionId, selectedSettings);
|
||||
}
|
||||
|
||||
async function loadExtensions() {
|
||||
const payload = await api('/api/extensions/config');
|
||||
extensions = Array.isArray(payload.extensions) ? payload.extensions : [];
|
||||
|
||||
if (!selectedExtensionId || !findExtension(selectedExtensionId)) {
|
||||
selectedExtensionId = extensions.length > 0 ? (extensions[0].id || '') : '';
|
||||
}
|
||||
|
||||
renderExtensionList();
|
||||
if (selectedExtensionId) {
|
||||
await selectExtension(selectedExtensionId);
|
||||
} else {
|
||||
selectedSettings = {};
|
||||
clearConfigForm('No installed extensions found.');
|
||||
document.getElementById('editorTitle').textContent = 'Settings';
|
||||
}
|
||||
}
|
||||
|
||||
function collectPlayerManagementSettings() {
|
||||
const wordsRaw = document.getElementById('pmWords').value || '';
|
||||
const words = wordsRaw
|
||||
.split(/\r?\n/)
|
||||
.map(x => x.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const minutesRaw = Number(document.getElementById('pmMinutes').value || 0);
|
||||
const minutes = Number.isFinite(minutesRaw) ? Math.max(0, Math.min(43200, Math.floor(minutesRaw))) : 15;
|
||||
|
||||
return {
|
||||
badWordFilter: {
|
||||
enabled: document.getElementById('pmEnabled').checked,
|
||||
words,
|
||||
autoMuteMinutes: minutes,
|
||||
autoMuteReason: (document.getElementById('pmReason').value || 'Inappropriate language').trim() || 'Inappropriate language',
|
||||
cancelMessage: document.getElementById('pmCancel').checked
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parseGenericValue(type, value) {
|
||||
if (type === 'number') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
return String(value == null ? '' : value);
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function collectGenericSettings() {
|
||||
const result = {};
|
||||
document.querySelectorAll('#kvList .kv-row').forEach(row => {
|
||||
const key = (row.querySelector('.kv-key').value || '').trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const type = row.querySelector('.kv-type').value || 'string';
|
||||
const rawValue = row.querySelector('.kv-value').value || '';
|
||||
result[key] = parseGenericValue(type, rawValue);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectSettingsFromForm() {
|
||||
if (selectedExtensionId === 'player-management') {
|
||||
return collectPlayerManagementSettings();
|
||||
}
|
||||
return collectGenericSettings();
|
||||
}
|
||||
|
||||
async function saveCurrentSettings() {
|
||||
if (!selectedExtensionId) {
|
||||
setStatus('Select an extension first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = collectSettingsFromForm();
|
||||
|
||||
await api(`/api/extensions/config/${encodeURIComponent(selectedExtensionId)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings })
|
||||
});
|
||||
|
||||
selectedSettings = settings;
|
||||
setStatus(`Saved settings for ${selectedExtensionId}.`, false);
|
||||
}
|
||||
|
||||
document.getElementById('saveBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await saveCurrentSettings();
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Could not save extension settings.', true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('reloadBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await loadExtensions();
|
||||
setStatus('Reloaded extension settings.', false);
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Could not reload extension settings.', true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('addFieldBtn').addEventListener('click', () => {
|
||||
if (!selectedExtensionId) {
|
||||
setStatus('Select an extension first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedExtensionId === 'player-management') {
|
||||
setStatus('Player management uses dedicated fields.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
addGenericFieldRow('', '', 'string');
|
||||
setStatus('Added new field row.', false);
|
||||
});
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await loadMe();
|
||||
await loadExtensions();
|
||||
} catch (error) {
|
||||
if (error && error.status === 401) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
setStatus(error && error.message ? error.message : 'Could not load extension config page.', true);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Extensions</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
<style>
|
||||
.ext-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ext-link:hover {
|
||||
color: #9ec0ff;
|
||||
border-bottom-color: #9ec0ff;
|
||||
}
|
||||
|
||||
.ext-download {
|
||||
display: inline-block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--button-border);
|
||||
background: var(--button-bg);
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.ext-download:hover {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
|
||||
.ext-status {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--table-row-border);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ext-status-up {
|
||||
color: #8af0b4;
|
||||
background: rgba(46, 204, 113, 0.18);
|
||||
border-color: rgba(46, 204, 113, 0.5);
|
||||
}
|
||||
|
||||
.ext-status-outdated {
|
||||
color: #ffe08b;
|
||||
background: rgba(241, 196, 15, 0.18);
|
||||
border-color: rgba(241, 196, 15, 0.5);
|
||||
}
|
||||
|
||||
.ext-status-unknown {
|
||||
color: #c7d1ea;
|
||||
background: rgba(127, 140, 170, 0.18);
|
||||
border-color: rgba(127, 140, 170, 0.5);
|
||||
}
|
||||
|
||||
.ext-status-restart {
|
||||
color: #9ec0ff;
|
||||
background: rgba(81, 125, 255, 0.2);
|
||||
border-color: rgba(81, 125, 255, 0.45);
|
||||
}
|
||||
|
||||
.ext-status-installed {
|
||||
color: #8af0b4;
|
||||
background: rgba(46, 204, 113, 0.18);
|
||||
border-color: rgba(46, 204, 113, 0.5);
|
||||
}
|
||||
|
||||
.ext-warning {
|
||||
margin: 10px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(241, 196, 15, 0.45);
|
||||
background: rgba(241, 196, 15, 0.14);
|
||||
color: #ffe08b;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
<a class="side-link active" href="/dashboard/extensions">Extensions</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Extensions</h1>
|
||||
<p>Shows loaded extensions and extension downloads from the latest GitHub release/pre-release.</p>
|
||||
<div id="extensionsWarning" class="ext-warning" style="display:none;"></div>
|
||||
<div id="extensionsInstallMessage" class="ext-warning" style="display:none;"></div>
|
||||
|
||||
<div class="toolbar">
|
||||
<select id="releaseChannel" style="width:auto;min-width:180px;">
|
||||
<option value="release">Release</option>
|
||||
<option value="prerelease">Pre-release</option>
|
||||
</select>
|
||||
<button id="reloadExtensions" class="secondary">Reload Extensions</button>
|
||||
<button id="refresh" class="secondary">Refresh</button>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Installed Extensions (<span id="installedCount">0</span>)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="installedBody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Available Extensions on GitHub (<span id="availableExtensionCount">0</span>)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Release</th>
|
||||
<th>Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="availableExtensionsBody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const nextExpanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', nextExpanded);
|
||||
toggle.classList.toggle('expanded', nextExpanded);
|
||||
savedState[category] = nextExpanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Request failed');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
function renderInstalled(installed) {
|
||||
const body = document.getElementById('installedBody');
|
||||
body.innerHTML = '';
|
||||
|
||||
if (!Array.isArray(installed) || installed.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td colspan="4">No extensions loaded</td>';
|
||||
body.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extension of installed) {
|
||||
const row = document.createElement('tr');
|
||||
const status = extension.status || 'unknown';
|
||||
const statusText = extension.statusText || 'Unknown';
|
||||
const statusClass = status === 'outdated'
|
||||
? 'ext-status ext-status-outdated'
|
||||
: (status === 'up_to_date' ? 'ext-status ext-status-up' : 'ext-status ext-status-unknown');
|
||||
const statusMarkup = `<span class="${statusClass}">${statusText}</span>`;
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${extension.id}</td>
|
||||
<td>${extension.displayName}</td>
|
||||
<td>${extension.source}</td>
|
||||
<td>${statusMarkup}</td>
|
||||
`;
|
||||
body.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function installStateBadge(state) {
|
||||
if (state === 'restart_required') {
|
||||
return '<span class="ext-status ext-status-restart">Restart required</span>';
|
||||
}
|
||||
if (state === 'installed') {
|
||||
return '<span class="ext-status ext-status-installed">Installed</span>';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderAvailableExtensions(availableExtensions) {
|
||||
const body = document.getElementById('availableExtensionsBody');
|
||||
body.innerHTML = '';
|
||||
|
||||
if (!Array.isArray(availableExtensions) || availableExtensions.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td colspan="4">No MinePanel extension assets found on GitHub releases</td>';
|
||||
body.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extension of availableExtensions) {
|
||||
const name = extension.name || extension.id || 'Unknown';
|
||||
const description = extension.description || '-';
|
||||
const release = extension.release || '-';
|
||||
const projectUrl = extension.projectUrl || '#';
|
||||
const extensionId = extension.extensionId || '';
|
||||
const fileName = extension.id || '';
|
||||
const installState = extension.installState || 'not_installed';
|
||||
const prereleaseSuffix = extension.prerelease ? ' (pre-release)' : '';
|
||||
const actionMarkup = installState === 'restart_required'
|
||||
? '<span class="ext-status ext-status-restart">Restart required</span>'
|
||||
: (installState === 'installed'
|
||||
? '<span class="ext-status ext-status-installed">Installed</span>'
|
||||
: `<button class="ext-download" data-install-extension="${extensionId}" data-install-file="${fileName}">Install</button>`);
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td><a class="ext-link" href="${projectUrl}" target="_blank" rel="noopener noreferrer">${name}</a></td>
|
||||
<td>${description}</td>
|
||||
<td>${release}${prereleaseSuffix}</td>
|
||||
<td>${actionMarkup}</td>
|
||||
`;
|
||||
body.appendChild(row);
|
||||
}
|
||||
|
||||
body.querySelectorAll('button[data-install-extension]').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const channel = document.getElementById('releaseChannel').value || 'release';
|
||||
const selectedExtensionId = button.dataset.installExtension || '';
|
||||
const selectedFile = button.dataset.installFile || '';
|
||||
await installExtension(channel, selectedExtensionId, selectedFile, button);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showInstallMessage(message, isError) {
|
||||
const banner = document.getElementById('extensionsInstallMessage');
|
||||
banner.textContent = message || '';
|
||||
banner.style.display = message ? 'block' : 'none';
|
||||
if (isError) {
|
||||
banner.style.borderColor = 'rgba(231, 76, 60, 0.45)';
|
||||
banner.style.background = 'rgba(231, 76, 60, 0.16)';
|
||||
banner.style.color = '#ffc7c1';
|
||||
return;
|
||||
}
|
||||
banner.style.borderColor = 'rgba(81, 125, 255, 0.45)';
|
||||
banner.style.background = 'rgba(81, 125, 255, 0.16)';
|
||||
banner.style.color = '#bfd1ff';
|
||||
}
|
||||
|
||||
async function installExtension(channel, extensionId, fileName, button) {
|
||||
if (!extensionId || !fileName) {
|
||||
showInstallMessage('Invalid extension selection.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Installing...';
|
||||
showInstallMessage('', false);
|
||||
|
||||
try {
|
||||
const response = await api('/api/extensions/install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channel, extensionId, fileName })
|
||||
});
|
||||
const text = response.message || 'Downloaded extension. Restart required.';
|
||||
showInstallMessage(text, false);
|
||||
await loadExtensionStatus();
|
||||
} catch (error) {
|
||||
showInstallMessage(error.message || 'Could not install extension.', true);
|
||||
button.disabled = false;
|
||||
button.textContent = previousText;
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadExtensions(button) {
|
||||
const previousText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Reloading...';
|
||||
showInstallMessage('', false);
|
||||
|
||||
try {
|
||||
const payload = await api('/api/extensions/reload', { method: 'POST' });
|
||||
const warningText = Array.isArray(payload.warnings) && payload.warnings.length > 0
|
||||
? ` Warnings: ${payload.warnings.join(', ')}`
|
||||
: '';
|
||||
showInstallMessage((payload.message || 'Extensions reloaded.') + warningText, false);
|
||||
await loadExtensionStatus();
|
||||
} catch (error) {
|
||||
showInstallMessage(error.message || 'Could not reload extensions.', true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = previousText;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExtensionStatus() {
|
||||
const channel = document.getElementById('releaseChannel').value || 'release';
|
||||
const status = await api(`/api/extensions/status?channel=${encodeURIComponent(channel)}`);
|
||||
if (status.channel) {
|
||||
document.getElementById('releaseChannel').value = status.channel;
|
||||
}
|
||||
|
||||
const warning = document.getElementById('extensionsWarning');
|
||||
if (status.availableExtensionsWarning) {
|
||||
warning.textContent = status.availableExtensionsWarning;
|
||||
warning.style.display = 'block';
|
||||
} else {
|
||||
warning.textContent = '';
|
||||
warning.style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('installedCount').textContent = String(status.installedCount || 0);
|
||||
document.getElementById('availableExtensionCount').textContent = String(status.availableExtensionCount || 0);
|
||||
renderInstalled(status.installed);
|
||||
renderAvailableExtensions(status.availableExtensions);
|
||||
}
|
||||
|
||||
document.getElementById('releaseChannel').addEventListener('change', async () => {
|
||||
await loadExtensionStatus();
|
||||
});
|
||||
|
||||
document.getElementById('refresh').addEventListener('click', async () => {
|
||||
showInstallMessage('', false);
|
||||
await loadExtensionStatus();
|
||||
});
|
||||
|
||||
document.getElementById('reloadExtensions').addEventListener('click', async () => {
|
||||
const button = document.getElementById('reloadExtensions');
|
||||
await reloadExtensions(button);
|
||||
});
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await loadMe();
|
||||
await loadExtensionStatus();
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Maintenance</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
<style>
|
||||
.maintenance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.maintenance-stat {
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--panel-bg);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.maintenance-stat-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted-text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.maintenance-stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maintenance-online {
|
||||
color: #8af0b4;
|
||||
}
|
||||
|
||||
.maintenance-offline {
|
||||
color: #ff9ca1;
|
||||
}
|
||||
|
||||
.maintenance-reason {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
padding: 10px 11px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
min-height: 84px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.maintenance-reason:focus {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.maintenance-motd {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.maintenance-note {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.maintenance-message {
|
||||
margin-top: 12px;
|
||||
padding: 9px 11px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--table-row-border);
|
||||
background: var(--panel-bg);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.maintenance-message.error {
|
||||
border-color: rgba(231, 76, 60, 0.45);
|
||||
background: rgba(231, 76, 60, 0.14);
|
||||
color: #ffc7c1;
|
||||
}
|
||||
|
||||
.maintenance-message.success {
|
||||
border-color: rgba(46, 204, 113, 0.45);
|
||||
background: rgba(46, 204, 113, 0.14);
|
||||
color: #b9f8cf;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
<a class="side-link" href="/dashboard/extensions">Extensions</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Maintenance</h1>
|
||||
<p>Enable maintenance mode, block non-staff joins, and remove non-staff players currently online.</p>
|
||||
|
||||
<section class="card">
|
||||
<h2>Status</h2>
|
||||
<div class="maintenance-grid">
|
||||
<div class="maintenance-stat">
|
||||
<div class="maintenance-stat-title">State</div>
|
||||
<div id="stateValue" class="maintenance-stat-value">Unknown</div>
|
||||
</div>
|
||||
<div class="maintenance-stat">
|
||||
<div class="maintenance-stat-title">Affected Players</div>
|
||||
<div id="affectedValue" class="maintenance-stat-value">0</div>
|
||||
</div>
|
||||
<div class="maintenance-stat">
|
||||
<div class="maintenance-stat-title">Last Changed</div>
|
||||
<div id="changedValue" class="maintenance-stat-value">-</div>
|
||||
</div>
|
||||
<div class="maintenance-stat">
|
||||
<div class="maintenance-stat-title">Bypass Permission</div>
|
||||
<div id="bypassValue" class="maintenance-stat-value">minepanel.maintenance.bypass</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="maintenance-note">Operators and players with bypass permission can still join.</p>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Controls</h2>
|
||||
<label for="reasonInput">Maintenance reason</label>
|
||||
<textarea id="reasonInput" class="maintenance-reason" placeholder="Server maintenance in progress"></textarea>
|
||||
<label class="maintenance-motd" for="motdInput">Maintenance MOTD</label>
|
||||
<input id="motdInput" type="text" placeholder="Maintenance mode is active">
|
||||
<div class="toolbar">
|
||||
<button id="enableKickBtn">Enable + Kick Non-Staff</button>
|
||||
<button id="enableBtn" class="secondary">Enable Only</button>
|
||||
<button id="disableBtn" class="danger">Disable</button>
|
||||
<button id="kickBtn" class="secondary">Kick Non-Staff Now</button>
|
||||
<button id="refreshBtn" class="secondary">Refresh</button>
|
||||
</div>
|
||||
<div id="message" class="maintenance-message"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const nextExpanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', nextExpanded);
|
||||
toggle.classList.toggle('expanded', nextExpanded);
|
||||
savedState[category] = nextExpanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Request failed');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
function setMessage(message, kind) {
|
||||
const messageElement = document.getElementById('message');
|
||||
messageElement.textContent = message || '';
|
||||
messageElement.className = 'maintenance-message';
|
||||
if (!message) {
|
||||
messageElement.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === 'error') {
|
||||
messageElement.classList.add('error');
|
||||
} else {
|
||||
messageElement.classList.add('success');
|
||||
}
|
||||
messageElement.style.display = 'block';
|
||||
}
|
||||
|
||||
function applyStatus(status) {
|
||||
const stateElement = document.getElementById('stateValue');
|
||||
const enabled = status.enabled === true;
|
||||
stateElement.textContent = enabled ? 'Enabled' : 'Disabled';
|
||||
stateElement.classList.toggle('maintenance-online', enabled);
|
||||
stateElement.classList.toggle('maintenance-offline', !enabled);
|
||||
|
||||
document.getElementById('affectedValue').textContent = String(status.affectedOnlinePlayers || 0);
|
||||
document.getElementById('bypassValue').textContent = status.bypassPermission || 'minepanel.maintenance.bypass';
|
||||
|
||||
const changedBy = status.changedBy || 'SYSTEM';
|
||||
const changedAt = Number(status.changedAt || 0);
|
||||
const changedText = changedAt > 0 ? `${new Date(changedAt).toLocaleString()} by ${changedBy}` : `- by ${changedBy}`;
|
||||
document.getElementById('changedValue').textContent = changedText;
|
||||
|
||||
const reasonInput = document.getElementById('reasonInput');
|
||||
if (typeof status.reason === 'string' && status.reason.length > 0) {
|
||||
reasonInput.value = status.reason;
|
||||
}
|
||||
|
||||
const motdInput = document.getElementById('motdInput');
|
||||
if (typeof status.motd === 'string' && status.motd.length > 0) {
|
||||
motdInput.value = status.motd;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
const status = await api('/api/extensions/maintenance/status');
|
||||
applyStatus(status);
|
||||
}
|
||||
|
||||
async function toggleMaintenance(enable, kickNonStaff) {
|
||||
const reason = document.getElementById('reasonInput').value.trim();
|
||||
const motd = document.getElementById('motdInput').value.trim();
|
||||
const endpoint = enable ? '/api/extensions/maintenance/enable' : '/api/extensions/maintenance/disable';
|
||||
const body = enable ? { reason, motd, kickNonStaff: kickNonStaff === true } : {};
|
||||
|
||||
const result = await api(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
applyStatus(result);
|
||||
const kickedPart = Number(result.kicked || 0) > 0 ? ` Kicked ${result.kicked} player(s).` : '';
|
||||
setMessage(enable ? `Maintenance enabled.${kickedPart}` : 'Maintenance disabled.', 'success');
|
||||
}
|
||||
|
||||
async function kickNonStaffNow() {
|
||||
const result = await api('/api/extensions/maintenance/kick-nonstaff', { method: 'POST' });
|
||||
applyStatus(result);
|
||||
setMessage(`Kicked ${result.kicked || 0} player(s).`, 'success');
|
||||
}
|
||||
|
||||
document.getElementById('enableKickBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await toggleMaintenance(true, true);
|
||||
} catch (error) {
|
||||
setMessage(error.message || 'Could not enable maintenance.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('enableBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await toggleMaintenance(true, false);
|
||||
} catch (error) {
|
||||
setMessage(error.message || 'Could not enable maintenance.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('disableBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await toggleMaintenance(false, false);
|
||||
} catch (error) {
|
||||
setMessage(error.message || 'Could not disable maintenance.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('kickBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await kickNonStaffNow();
|
||||
} catch (error) {
|
||||
setMessage(error.message || 'Could not kick players.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await refreshStatus();
|
||||
setMessage('', 'success');
|
||||
} catch (error) {
|
||||
setMessage(error.message || 'Could not refresh status.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await loadMe();
|
||||
await refreshStatus();
|
||||
setInterval(() => {
|
||||
refreshStatus().catch(() => {});
|
||||
}, 3000);
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Overview</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link active" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Overview</h1>
|
||||
<section class="overview-top-grid spaced-top">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">TPS <span id="tpsStatus" class="metric-status metric-unknown">Unavailable</span></div>
|
||||
<div id="statTps" class="stat-value">-</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Memory Usage</div>
|
||||
<div id="statMemory" class="stat-value">0 MB / 0 MB</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">CPU Usage</div>
|
||||
<div id="statCpu" class="stat-value">-</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>TPS Over Time (Last Hour)</h2>
|
||||
<div class="chart-wrapper chart-tall">
|
||||
<canvas id="tpsChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-bottom-grid spaced-top">
|
||||
<div class="card">
|
||||
<h2>Memory Over Time</h2>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="memoryChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>CPU Over Time</h2>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="cpuChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-bottom-grid spaced-top">
|
||||
<div class="card">
|
||||
<h2>Join Heatmap (Day x Hour)</h2>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="joinHeatmapChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Leave Heatmap (Day x Hour)</h2>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="leaveHeatmapChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let latestHistory = { tps: [], memory: [], cpu: [] };
|
||||
let latestHeatmaps = { join: [], leave: [], maxJoin: 0, maxLeave: 0 };
|
||||
const heatmapRenderMeta = {};
|
||||
const heatmapTooltip = createHeatmapTooltip();
|
||||
|
||||
async function api(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
const metrics = await api('/api/overview/metrics');
|
||||
const current = metrics.current || {};
|
||||
latestHistory = metrics.history || { tps: [], memory: [], cpu: [] };
|
||||
latestHeatmaps = {
|
||||
join: Array.isArray(metrics.joinHeatmap) ? metrics.joinHeatmap : [],
|
||||
leave: Array.isArray(metrics.leaveHeatmap) ? metrics.leaveHeatmap : [],
|
||||
maxJoin: Number(metrics.heatmapMaxJoin) || 0,
|
||||
maxLeave: Number(metrics.heatmapMaxLeave) || 0
|
||||
};
|
||||
|
||||
updateTopMetrics(current);
|
||||
drawAllCharts();
|
||||
}
|
||||
|
||||
function updateTopMetrics(current) {
|
||||
const tps = Number(current.tps);
|
||||
const memoryUsedMb = Number(current.memoryUsedMb);
|
||||
const memoryMaxMb = Number(current.memoryMaxMb);
|
||||
const cpuPercent = Number(current.cpuPercent);
|
||||
|
||||
if (Number.isFinite(tps) && tps >= 0) {
|
||||
document.getElementById('statTps').textContent = tps.toFixed(2);
|
||||
} else {
|
||||
document.getElementById('statTps').textContent = '-';
|
||||
}
|
||||
|
||||
document.getElementById('statMemory').textContent = Number.isFinite(memoryUsedMb) && Number.isFinite(memoryMaxMb) && memoryMaxMb > 0
|
||||
? `${Math.max(0, memoryUsedMb).toFixed(0)} MB / ${Math.max(0, memoryMaxMb).toFixed(0)} MB`
|
||||
: 'Unavailable';
|
||||
|
||||
document.getElementById('statCpu').textContent = Number.isFinite(cpuPercent) && cpuPercent >= 0
|
||||
? `${cpuPercent.toFixed(1)}%`
|
||||
: 'Unavailable';
|
||||
|
||||
const status = resolveTpsStatus(tps);
|
||||
const statusElement = document.getElementById('tpsStatus');
|
||||
statusElement.textContent = status.label;
|
||||
statusElement.className = `metric-status ${status.cssClass}`;
|
||||
}
|
||||
|
||||
function resolveTpsStatus(tps) {
|
||||
if (!Number.isFinite(tps) || tps < 0) {
|
||||
return { label: 'Unavailable', cssClass: 'metric-unknown' };
|
||||
}
|
||||
if (tps >= 18.0) {
|
||||
return { label: 'Good', cssClass: 'metric-good' };
|
||||
}
|
||||
if (tps >= 15.0) {
|
||||
return { label: 'Medium', cssClass: 'metric-medium' };
|
||||
}
|
||||
return { label: 'Bad', cssClass: 'metric-bad' };
|
||||
}
|
||||
|
||||
function themeColor(name, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function drawAllCharts() {
|
||||
drawSeriesChart('tpsChart', latestHistory.tps || [], {
|
||||
minY: 0,
|
||||
maxY: 20,
|
||||
color: '#58d68d',
|
||||
fill: 'rgba(88, 214, 141, 0.18)'
|
||||
});
|
||||
drawSeriesChart('memoryChart', latestHistory.memory || [], {
|
||||
minY: 0,
|
||||
maxY: 100,
|
||||
color: '#5da5ff',
|
||||
fill: 'rgba(93, 165, 255, 0.18)'
|
||||
});
|
||||
drawSeriesChart('cpuChart', latestHistory.cpu || [], {
|
||||
minY: 0,
|
||||
maxY: 100,
|
||||
color: '#f6c85f',
|
||||
fill: 'rgba(246, 200, 95, 0.18)'
|
||||
});
|
||||
drawHeatmap('joinHeatmapChart', latestHeatmaps.join, latestHeatmaps.maxJoin, '#58d68d');
|
||||
drawHeatmap('leaveHeatmapChart', latestHeatmaps.leave, latestHeatmaps.maxLeave, '#ff8a8a');
|
||||
}
|
||||
|
||||
function drawHeatmap(canvasId, cells, maxCount, accentColor) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
|
||||
canvas.width = Math.max(1, Math.floor(width * dpr));
|
||||
canvas.height = Math.max(1, Math.floor(height * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const chartBackground = themeColor('--chart-bg', '#0b1427');
|
||||
const chartLabel = themeColor('--chart-label', '#8ea6d2');
|
||||
const chartGrid = themeColor('--chart-grid', 'rgba(120, 150, 210, 0.22)');
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.fillStyle = chartBackground;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const padding = { left: 48, right: 10, top: 10, bottom: 22 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
if (chartWidth <= 0 || chartHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const cellWidth = chartWidth / 24;
|
||||
const cellHeight = chartHeight / 7;
|
||||
const maxValue = Math.max(1, Number(maxCount) || 0);
|
||||
|
||||
const byKey = new Map();
|
||||
for (const cell of Array.isArray(cells) ? cells : []) {
|
||||
const day = Number(cell.day);
|
||||
const hour = Number(cell.hour);
|
||||
const count = Number(cell.count);
|
||||
if (!Number.isFinite(day) || !Number.isFinite(hour) || !Number.isFinite(count)) {
|
||||
continue;
|
||||
}
|
||||
byKey.set(`${day}:${hour}`, Math.max(0, Math.floor(count)));
|
||||
}
|
||||
|
||||
for (let day = 0; day < 7; day += 1) {
|
||||
for (let hour = 0; hour < 24; hour += 1) {
|
||||
const count = byKey.get(`${day}:${hour}`) || 0;
|
||||
const normalized = count <= 0 ? 0 : Math.pow(Math.min(1, count / maxValue), 0.6);
|
||||
const alpha = count <= 0 ? 0.03 : Math.min(1, 0.1 + normalized * 0.9);
|
||||
const x = padding.left + hour * cellWidth;
|
||||
const y = padding.top + day * cellHeight;
|
||||
|
||||
ctx.fillStyle = toRgba(accentColor, alpha);
|
||||
ctx.fillRect(x + 1, y + 1, Math.max(1, cellWidth - 2), Math.max(1, cellHeight - 2));
|
||||
}
|
||||
}
|
||||
|
||||
ctx.strokeStyle = chartGrid;
|
||||
ctx.lineWidth = 1;
|
||||
for (let day = 0; day <= 7; day += 1) {
|
||||
const y = padding.top + day * cellHeight;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(padding.left + chartWidth, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let hour = 0; hour <= 24; hour += 1) {
|
||||
const x = padding.left + hour * cellWidth;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padding.top);
|
||||
ctx.lineTo(x, padding.top + chartHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStyle = chartLabel;
|
||||
ctx.font = '11px Segoe UI';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'right';
|
||||
for (let day = 0; day < 7; day += 1) {
|
||||
const y = padding.top + (day + 0.5) * cellHeight;
|
||||
ctx.fillText(dayLabels[day], padding.left - 6, y);
|
||||
}
|
||||
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
for (let hour = 0; hour < 24; hour += 6) {
|
||||
const x = padding.left + (hour + 0.5) * cellWidth;
|
||||
ctx.fillText(`${hour}:00`, x, padding.top + chartHeight + 4);
|
||||
}
|
||||
|
||||
heatmapRenderMeta[canvasId] = {
|
||||
padding,
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
byKey,
|
||||
dayLabels
|
||||
};
|
||||
}
|
||||
|
||||
function createHeatmapTooltip() {
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.style.position = 'fixed';
|
||||
tooltip.style.zIndex = '1000';
|
||||
tooltip.style.padding = '6px 8px';
|
||||
tooltip.style.border = '1px solid var(--chart-border, #213457)';
|
||||
tooltip.style.background = 'var(--surface, #111a2e)';
|
||||
tooltip.style.color = 'var(--text, #e5ebf8)';
|
||||
tooltip.style.borderRadius = '6px';
|
||||
tooltip.style.font = '12px Segoe UI, sans-serif';
|
||||
tooltip.style.pointerEvents = 'none';
|
||||
tooltip.style.display = 'none';
|
||||
document.body.appendChild(tooltip);
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
function setupHeatmapHover(canvasId, mode) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas || canvas.dataset.hoverBound === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.dataset.hoverBound = '1';
|
||||
|
||||
canvas.addEventListener('mousemove', event => {
|
||||
const meta = heatmapRenderMeta[canvasId];
|
||||
if (!meta) {
|
||||
heatmapTooltip.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
const insideX = x >= meta.padding.left && x <= (meta.padding.left + meta.chartWidth);
|
||||
const insideY = y >= meta.padding.top && y <= (meta.padding.top + meta.chartHeight);
|
||||
if (!insideX || !insideY) {
|
||||
heatmapTooltip.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const hour = Math.max(0, Math.min(23, Math.floor((x - meta.padding.left) / meta.cellWidth)));
|
||||
const day = Math.max(0, Math.min(6, Math.floor((y - meta.padding.top) / meta.cellHeight)));
|
||||
const count = meta.byKey.get(`${day}:${hour}`) || 0;
|
||||
const verb = mode === 'join' ? 'joined' : 'left';
|
||||
const dayLabel = meta.dayLabels[day] || 'Unknown';
|
||||
|
||||
heatmapTooltip.textContent = `${dayLabel} ${hour}:00-${hour + 1}:00: ${count} ${verb}`;
|
||||
heatmapTooltip.style.left = `${event.clientX + 12}px`;
|
||||
heatmapTooltip.style.top = `${event.clientY + 12}px`;
|
||||
heatmapTooltip.style.display = 'block';
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseleave', () => {
|
||||
heatmapTooltip.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function toRgba(hex, alpha) {
|
||||
const normalized = (hex || '').trim();
|
||||
const parsed = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(normalized);
|
||||
if (!parsed) {
|
||||
return `rgba(88, 140, 255, ${alpha})`;
|
||||
}
|
||||
|
||||
const r = parseInt(parsed[1], 16);
|
||||
const g = parseInt(parsed[2], 16);
|
||||
const b = parseInt(parsed[3], 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
function drawSeriesChart(canvasId, points, options) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
|
||||
canvas.width = Math.max(1, Math.floor(width * dpr));
|
||||
canvas.height = Math.max(1, Math.floor(height * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const chartBackground = themeColor('--chart-bg', '#0b1427');
|
||||
const chartGrid = themeColor('--chart-grid', 'rgba(120, 150, 210, 0.22)');
|
||||
const chartLabel = themeColor('--chart-label', '#8ea6d2');
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.fillStyle = chartBackground;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const padding = { left: 40, right: 16, top: 12, bottom: 24 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
if (chartWidth <= 0 || chartHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minY = options.minY;
|
||||
const maxY = options.maxY;
|
||||
const safePoints = (Array.isArray(points) ? points : [])
|
||||
.map(point => ({
|
||||
timestamp: Number(point.timestamp),
|
||||
value: Number(point.value)
|
||||
}))
|
||||
.filter(point => Number.isFinite(point.timestamp) && Number.isFinite(point.value) && point.value >= 0);
|
||||
|
||||
const now = Date.now();
|
||||
const minX = now - (60 * 60 * 1000);
|
||||
const maxX = now;
|
||||
|
||||
ctx.strokeStyle = chartGrid;
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i += 1) {
|
||||
const y = padding.top + (chartHeight * i / 4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(width - padding.right, y);
|
||||
ctx.stroke();
|
||||
|
||||
const labelValue = maxY - ((maxY - minY) * i / 4);
|
||||
ctx.fillStyle = chartLabel;
|
||||
ctx.font = '11px Segoe UI';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(labelValue.toFixed(0), padding.left - 6, y);
|
||||
}
|
||||
|
||||
if (safePoints.length === 0) {
|
||||
ctx.fillStyle = chartLabel;
|
||||
ctx.font = '13px Segoe UI';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('No data yet', width / 2, height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const toX = timestamp => padding.left + ((timestamp - minX) / (maxX - minX)) * chartWidth;
|
||||
const toY = value => padding.top + ((maxY - value) / (maxY - minY)) * chartHeight;
|
||||
|
||||
ctx.beginPath();
|
||||
safePoints.forEach((point, index) => {
|
||||
const x = toX(Math.max(minX, Math.min(maxX, point.timestamp)));
|
||||
const y = toY(Math.max(minY, Math.min(maxY, point.value)));
|
||||
if (index === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.lineTo(toX(Math.max(minX, Math.min(maxX, safePoints[safePoints.length - 1].timestamp))), padding.top + chartHeight);
|
||||
ctx.lineTo(toX(Math.max(minX, Math.min(maxX, safePoints[0].timestamp))), padding.top + chartHeight);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = options.fill;
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
safePoints.forEach((point, index) => {
|
||||
const x = toX(Math.max(minX, Math.min(maxX, point.timestamp)));
|
||||
const y = toY(Math.max(minY, Math.min(maxY, point.value)));
|
||||
if (index === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.strokeStyle = options.color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = chartLabel;
|
||||
ctx.font = '11px Segoe UI';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText('1h ago', padding.left, height - 18);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('now', width - padding.right, height - 18);
|
||||
}
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
setupHeatmapHover('joinHeatmapChart', 'join');
|
||||
setupHeatmapHover('leaveHeatmapChart', 'leave');
|
||||
await Promise.all([loadMe(), loadOverview()]);
|
||||
setInterval(() => {
|
||||
loadOverview().catch(() => {});
|
||||
}, 5000);
|
||||
window.addEventListener('resize', () => {
|
||||
drawAllCharts();
|
||||
});
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Players</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link active" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Players</h1>
|
||||
<p>All players that ever joined: <span id="playerCount">0</span></p>
|
||||
|
||||
<section class="players-layout spaced-top">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Player List</h2>
|
||||
<input id="playerSearch" class="player-search" placeholder="Search player">
|
||||
</div>
|
||||
<ul id="playerDirectory" class="player-directory"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card profile-card">
|
||||
<h2>Player Profile</h2>
|
||||
<div id="profileEmpty" class="player-empty">Select a player from the list.</div>
|
||||
<div id="profileContent" class="profile-pane" style="display:none;">
|
||||
<div class="profile-header">
|
||||
<img id="profileAvatar" class="profile-avatar" alt="Player skin face">
|
||||
<div>
|
||||
<div class="profile-name-row">
|
||||
<span id="profileName" class="profile-name"></span>
|
||||
<span id="profileStatusDot" class="status-dot"></span>
|
||||
<span id="profileStatusText" class="profile-status"></span>
|
||||
</div>
|
||||
<div id="profileUuid" class="profile-uuid"></div>
|
||||
<div id="profileMuteState" class="profile-uuid" style="margin-top:6px;">Mute: -</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-grid">
|
||||
<div class="profile-field"><span>First joined</span><strong id="profileFirstJoined">-</strong></div>
|
||||
<div class="profile-field"><span>Last seen</span><strong id="profileLastSeen">-</strong></div>
|
||||
<div class="profile-field"><span>Total playtime</span><strong id="profilePlaytime">-</strong></div>
|
||||
<div class="profile-field"><span>Total sessions</span><strong id="profileSessions">-</strong></div>
|
||||
<div class="profile-field"><span>IP Address</span><strong id="profileIp">-</strong></div>
|
||||
<div class="profile-field"><span>Country</span><strong id="profileCountry">-</strong></div>
|
||||
</div>
|
||||
|
||||
<div id="playerStatsSection" class="card spaced-top" style="display:none;padding:12px;">
|
||||
<h3 style="margin:0 0 8px;">Player Stats</h3>
|
||||
<div class="profile-grid">
|
||||
<div class="profile-field"><span>Kills</span><strong id="statsKills">-</strong></div>
|
||||
<div class="profile-field"><span>Deaths</span><strong id="statsDeaths">-</strong></div>
|
||||
<div class="profile-field"><span>Money</span><strong id="statsMoney">-</strong></div>
|
||||
<div class="profile-field"><span>Economy Provider</span><strong id="statsEconomyProvider">-</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="luckPermsSection" class="card spaced-top" style="display:none;padding:12px;">
|
||||
<h3 style="margin:0 0 8px;">LuckPerms</h3>
|
||||
<div class="profile-grid">
|
||||
<div class="profile-field"><span>Primary Group</span><strong id="lpPrimaryGroup">-</strong></div>
|
||||
<div class="profile-field"><span>Prefix / Suffix</span><strong id="lpPrefixSuffix">-</strong></div>
|
||||
</div>
|
||||
<div class="profile-field spaced-top"><span>Groups</span><strong id="lpGroups">-</strong></div>
|
||||
<div class="profile-field spaced-top"><span>Permissions</span><strong id="lpPermissionsMeta">-</strong></div>
|
||||
<pre id="lpPermissions" class="log-rectangle" style="height:180px;margin-top:8px;"></pre>
|
||||
</div>
|
||||
|
||||
<div id="playerManagementActions" class="spaced-top" style="display:none;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;">
|
||||
<input id="actionReason" placeholder="Reason for action" style="grid-column:1 / -1;">
|
||||
<input id="actionDuration" type="number" min="1" value="60" placeholder="Duration (minutes)">
|
||||
<button id="kickButton" class="secondary">Kick</button>
|
||||
<button id="tempBanButton">Temp Ban</button>
|
||||
<button id="banButton">Ban</button>
|
||||
<button id="unbanButton" class="secondary">Unban</button>
|
||||
<button id="muteButton">Mute</button>
|
||||
<button id="unmuteButton" class="secondary">Unmute</button>
|
||||
</div>
|
||||
|
||||
<div id="airstrikeActions" class="spaced-top" style="display:none;grid-template-columns:180px minmax(0,1fr);gap:8px;align-items:center;">
|
||||
<button id="airstrikeButton" class="danger-button">Airstrike</button>
|
||||
<input id="airstrikeAmount" type="number" min="1" max="20000" step="1" value="5000" placeholder="TNT amount">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let allPlayers = [];
|
||||
let selectedUuid = null;
|
||||
let playerManagementAvailable = false;
|
||||
let luckPermsAvailable = false;
|
||||
let playerStatsAvailable = false;
|
||||
let airstrikeAvailable = false;
|
||||
let mePermissions = [];
|
||||
let selectedProfileRequestId = 0;
|
||||
const avatarCache = new Map();
|
||||
|
||||
function isCurrentProfileRequest(uuid, requestId) {
|
||||
return selectedUuid === uuid && selectedProfileRequestId === requestId;
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
mePermissions = Array.isArray(data.user.permissions) ? data.user.permissions : [];
|
||||
}
|
||||
|
||||
function hasPermission(permissionName) {
|
||||
return mePermissions.includes(permissionName);
|
||||
}
|
||||
|
||||
async function detectPlayerManagementExtension() {
|
||||
try {
|
||||
const response = await fetch('/api/extensions/player-management/mute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const data = await response.json();
|
||||
playerManagementAvailable = data.error !== 'not_found';
|
||||
} catch {
|
||||
playerManagementAvailable = false;
|
||||
}
|
||||
|
||||
const actions = document.getElementById('playerManagementActions');
|
||||
actions.style.display = playerManagementAvailable ? 'grid' : 'none';
|
||||
}
|
||||
|
||||
async function detectLuckPermsExtension() {
|
||||
const section = document.getElementById('luckPermsSection');
|
||||
luckPermsAvailable = false;
|
||||
section.style.display = 'none';
|
||||
|
||||
try {
|
||||
const probeUuid = selectedUuid || (allPlayers.length > 0 ? allPlayers[0].uuid : null);
|
||||
if (!probeUuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await api(`/api/extensions/luckperms/player/${encodeURIComponent(probeUuid)}`);
|
||||
luckPermsAvailable = data.available === true;
|
||||
} catch {
|
||||
luckPermsAvailable = false;
|
||||
}
|
||||
|
||||
section.style.display = luckPermsAvailable ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async function detectPlayerStatsExtension() {
|
||||
const section = document.getElementById('playerStatsSection');
|
||||
playerStatsAvailable = false;
|
||||
section.style.display = 'none';
|
||||
|
||||
try {
|
||||
const probeUuid = selectedUuid || (allPlayers.length > 0 ? allPlayers[0].uuid : null);
|
||||
if (!probeUuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await api(`/api/extensions/player-stats/player/${encodeURIComponent(probeUuid)}`);
|
||||
playerStatsAvailable = data.available === true;
|
||||
} catch {
|
||||
playerStatsAvailable = false;
|
||||
}
|
||||
|
||||
section.style.display = playerStatsAvailable ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async function detectAirstrikeExtension() {
|
||||
const actions = document.getElementById('airstrikeActions');
|
||||
airstrikeAvailable = false;
|
||||
actions.style.display = 'none';
|
||||
|
||||
if (!hasPermission('MANAGE_AIRSTRIKE')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/extensions/airstrike/launch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const data = await response.json();
|
||||
airstrikeAvailable = data.error !== 'not_found';
|
||||
} catch {
|
||||
airstrikeAvailable = false;
|
||||
}
|
||||
|
||||
actions.style.display = airstrikeAvailable ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp || Number(timestamp) <= 0) {
|
||||
return '-';
|
||||
}
|
||||
return new Date(Number(timestamp)).toLocaleString();
|
||||
}
|
||||
|
||||
function formatPlaytime(seconds) {
|
||||
const safe = Math.max(0, Number(seconds) || 0);
|
||||
const days = Math.floor(safe / 86400);
|
||||
const hours = Math.floor((safe % 86400) / 3600);
|
||||
const minutes = Math.floor((safe % 3600) / 60);
|
||||
const sec = Math.floor(safe % 60);
|
||||
|
||||
const parts = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0 || days > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0 || hours > 0 || days > 0) parts.push(`${minutes}m`);
|
||||
parts.push(`${sec}s`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function applyAvatar(img, uuid, username, requestId) {
|
||||
img.dataset.avatarUuid = uuid;
|
||||
img.dataset.avatarRequestId = String(requestId);
|
||||
delete img.dataset.fallbackTried;
|
||||
|
||||
const cached = avatarCache.get(uuid);
|
||||
if (cached) {
|
||||
img.onerror = null;
|
||||
img.onload = null;
|
||||
img.src = cached;
|
||||
return;
|
||||
}
|
||||
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.alt = `${username} skin face`;
|
||||
|
||||
function avatarRequestStillCurrent() {
|
||||
return img.dataset.avatarUuid === uuid
|
||||
&& img.dataset.avatarRequestId === String(requestId)
|
||||
&& isCurrentProfileRequest(uuid, requestId);
|
||||
}
|
||||
|
||||
img.src = `https://crafatar.com/avatars/${uuid}?size=48&overlay`;
|
||||
img.onerror = () => {
|
||||
if (!avatarRequestStillCurrent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!img.dataset.fallbackTried) {
|
||||
img.dataset.fallbackTried = '1';
|
||||
img.src = `https://mc-heads.net/avatar/${encodeURIComponent(username)}/48`;
|
||||
return;
|
||||
}
|
||||
|
||||
img.onerror = null;
|
||||
img.onload = null;
|
||||
img.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><rect width="48" height="48" fill="%23131f38"/><text x="24" y="30" text-anchor="middle" font-family="Arial" font-size="18" fill="%23a9bbdf">?</text></svg>';
|
||||
};
|
||||
img.onload = () => {
|
||||
if (!avatarRequestStillCurrent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSource = img.currentSrc || img.src || '';
|
||||
if (currentSource.startsWith('http')) {
|
||||
avatarCache.set(uuid, currentSource);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPlayerList() {
|
||||
const data = await api('/api/players');
|
||||
allPlayers = Array.isArray(data.allPlayers) ? data.allPlayers : [];
|
||||
document.getElementById('playerCount').textContent = String(allPlayers.length);
|
||||
renderPlayerDirectory();
|
||||
|
||||
if (selectedUuid) {
|
||||
const stillExists = allPlayers.some(player => player.uuid === selectedUuid);
|
||||
if (stillExists) {
|
||||
await loadPlayerProfile(selectedUuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlayerDirectory() {
|
||||
const search = document.getElementById('playerSearch').value.trim().toLowerCase();
|
||||
const list = document.getElementById('playerDirectory');
|
||||
list.innerHTML = '';
|
||||
|
||||
const filtered = allPlayers.filter(player => {
|
||||
if (!search) return true;
|
||||
return `${player.username} ${player.uuid}`.toLowerCase().includes(search);
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
const empty = document.createElement('li');
|
||||
empty.className = 'player-empty';
|
||||
empty.textContent = 'No players found';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach(player => {
|
||||
const item = document.createElement('li');
|
||||
item.className = `directory-item${player.uuid === selectedUuid ? ' active' : ''}`;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = `status-dot ${player.online ? 'online' : 'offline'}`;
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'directory-name';
|
||||
name.textContent = player.username;
|
||||
|
||||
item.appendChild(dot);
|
||||
item.appendChild(name);
|
||||
item.addEventListener('click', async () => {
|
||||
selectedUuid = player.uuid;
|
||||
renderPlayerDirectory();
|
||||
await loadPlayerProfile(player.uuid);
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMuteState(uuid, requestId) {
|
||||
const muteState = document.getElementById('profileMuteState');
|
||||
if (!isCurrentProfileRequest(uuid, requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playerManagementAvailable) {
|
||||
muteState.textContent = 'Mute: extension not installed';
|
||||
return;
|
||||
}
|
||||
|
||||
muteState.textContent = 'Mute: -';
|
||||
try {
|
||||
const data = await api(`/api/extensions/player-management/mute/${encodeURIComponent(uuid)}`);
|
||||
if (!isCurrentProfileRequest(uuid, requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.muted) {
|
||||
muteState.textContent = 'Mute: not muted';
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.expiresAt) {
|
||||
muteState.textContent = `Mute: active until ${new Date(Number(data.expiresAt)).toLocaleString()} (${data.reason || 'No reason'})`;
|
||||
} else {
|
||||
muteState.textContent = `Mute: active permanently (${data.reason || 'No reason'})`;
|
||||
}
|
||||
} catch {
|
||||
if (!isCurrentProfileRequest(uuid, requestId)) {
|
||||
return;
|
||||
}
|
||||
muteState.textContent = 'Mute: unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlayerProfile(uuid) {
|
||||
const requestId = ++selectedProfileRequestId;
|
||||
const data = await api(`/api/players/profile/${encodeURIComponent(uuid)}`);
|
||||
if (!isCurrentProfileRequest(uuid, requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = data.profile;
|
||||
|
||||
document.getElementById('profileEmpty').style.display = 'none';
|
||||
document.getElementById('profileContent').style.display = 'block';
|
||||
|
||||
document.getElementById('profileName').textContent = profile.username || 'Unknown';
|
||||
document.getElementById('profileUuid').textContent = profile.uuid || '-';
|
||||
|
||||
const statusDot = document.getElementById('profileStatusDot');
|
||||
statusDot.className = `status-dot ${profile.online ? 'online' : 'offline'}`;
|
||||
document.getElementById('profileStatusText').textContent = profile.online ? 'Online' : 'Offline';
|
||||
|
||||
document.getElementById('profileFirstJoined').textContent = formatDate(profile.firstJoined);
|
||||
document.getElementById('profileLastSeen').textContent = formatDate(profile.lastSeen);
|
||||
document.getElementById('profilePlaytime').textContent = formatPlaytime(profile.totalPlaytimeSeconds);
|
||||
document.getElementById('profileSessions').textContent = String(profile.totalSessions ?? 0);
|
||||
document.getElementById('profileIp').textContent = profile.lastIp || '-';
|
||||
document.getElementById('profileCountry').textContent = profile.country || 'Unknown';
|
||||
|
||||
applyAvatar(document.getElementById('profileAvatar'), profile.uuid, profile.username, requestId);
|
||||
await loadMuteState(profile.uuid, requestId);
|
||||
await loadPlayerStats(profile.uuid, requestId);
|
||||
await loadLuckPermsState(profile.uuid, requestId);
|
||||
}
|
||||
|
||||
async function loadPlayerStats(uuid, requestId) {
|
||||
const section = document.getElementById('playerStatsSection');
|
||||
if (!isCurrentProfileRequest(uuid, requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playerStatsAvailable) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
section.style.display = 'block';
|
||||
document.getElementById('statsKills').textContent = '-';
|
||||
document.getElementById('statsDeaths').textContent = '-';
|
||||
document.getElementById('statsMoney').textContent = '-';
|
||||
document.getElementById('statsEconomyProvider').textContent = '-';
|
||||
|
||||
try {
|
||||
const data = await api(`/api/extensions/player-stats/player/${encodeURIComponent(uuid)}`);
|
||||
if (!isCurrentProfileRequest(uuid, requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.available) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('statsKills').textContent = String(data.kills ?? 0);
|
||||
document.getElementById('statsDeaths').textContent = String(data.deaths ?? 0);
|
||||
|
||||
if (data.economyAvailable) {
|
||||
document.getElementById('statsMoney').textContent = data.balanceFormatted || '-';
|
||||
document.getElementById('statsEconomyProvider').textContent = data.economyProvider || '-';
|
||||
} else {
|
||||
document.getElementById('statsMoney').textContent = 'Economy not available';
|
||||
document.getElementById('statsEconomyProvider').textContent = '-';
|
||||
}
|
||||
} catch {
|
||||
if (!isCurrentProfileRequest(uuid, requestId)) {
|
||||
return;
|
||||
}
|
||||
section.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLuckPermsState(uuid, requestId) {
|
||||
const section = document.getElementById('luckPermsSection');
|
||||
if (!isCurrentProfileRequest(uuid, requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!luckPermsAvailable) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
section.style.display = 'block';
|
||||
document.getElementById('lpPrimaryGroup').textContent = '-';
|
||||
document.getElementById('lpPrefixSuffix').textContent = '-';
|
||||
document.getElementById('lpGroups').textContent = '-';
|
||||
document.getElementById('lpPermissionsMeta').textContent = 'Loading...';
|
||||
document.getElementById('lpPermissions').textContent = '';
|
||||
|
||||
try {
|
||||
const data = await api(`/api/extensions/luckperms/player/${encodeURIComponent(uuid)}`);
|
||||
if (!isCurrentProfileRequest(uuid, requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.available) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = Array.isArray(data.groups) ? data.groups : [];
|
||||
const permissions = Array.isArray(data.permissions) ? data.permissions : [];
|
||||
|
||||
document.getElementById('lpPrimaryGroup').textContent = data.primaryGroup || '-';
|
||||
document.getElementById('lpPrefixSuffix').textContent = `${data.prefix || '-'} / ${data.suffix || '-'}`;
|
||||
document.getElementById('lpGroups').textContent = groups.length > 0 ? groups.join(', ') : '-';
|
||||
|
||||
const totalPermissions = Number(data.permissionsCount) || permissions.length;
|
||||
const truncated = data.permissionsTruncated === true;
|
||||
document.getElementById('lpPermissionsMeta').textContent = truncated
|
||||
? `${permissions.length} shown of ${totalPermissions}`
|
||||
: `${totalPermissions} permission(s)`;
|
||||
document.getElementById('lpPermissions').textContent = permissions.length > 0
|
||||
? permissions.join('\n')
|
||||
: 'No granted permissions';
|
||||
} catch {
|
||||
if (!isCurrentProfileRequest(uuid, requestId)) {
|
||||
return;
|
||||
}
|
||||
section.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function selectedPlayerName() {
|
||||
if (!selectedUuid) return '';
|
||||
const player = allPlayers.find(x => x.uuid === selectedUuid);
|
||||
return player ? player.username : '';
|
||||
}
|
||||
|
||||
function actionReason(defaultValue) {
|
||||
const raw = document.getElementById('actionReason').value.trim();
|
||||
return raw || defaultValue;
|
||||
}
|
||||
|
||||
function actionDuration() {
|
||||
const parsed = Number(document.getElementById('actionDuration').value);
|
||||
if (!Number.isFinite(parsed)) return 60;
|
||||
return Math.max(1, Math.min(43200, Math.round(parsed)));
|
||||
}
|
||||
|
||||
function actionTntAmount() {
|
||||
const parsed = Number(document.getElementById('airstrikeAmount').value);
|
||||
if (!Number.isFinite(parsed)) return 5000;
|
||||
return Math.max(1, Math.min(20000, Math.round(parsed)));
|
||||
}
|
||||
|
||||
async function runPlayerAction(handler) {
|
||||
if (!selectedUuid) {
|
||||
alert('Select a player first.');
|
||||
return;
|
||||
}
|
||||
await handler();
|
||||
await loadPlayerList();
|
||||
await loadPlayerProfile(selectedUuid);
|
||||
}
|
||||
|
||||
document.getElementById('kickButton').addEventListener('click', async () => {
|
||||
await runPlayerAction(async () => {
|
||||
await api(`/api/players/${encodeURIComponent(selectedUuid)}/kick`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: actionReason('Kicked by panel moderator') })
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('tempBanButton').addEventListener('click', async () => {
|
||||
await runPlayerAction(async () => {
|
||||
await api(`/api/players/${encodeURIComponent(selectedUuid)}/temp-ban`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: selectedPlayerName(),
|
||||
durationMinutes: actionDuration(),
|
||||
reason: actionReason('Temporarily banned by panel moderator')
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('banButton').addEventListener('click', async () => {
|
||||
await runPlayerAction(async () => {
|
||||
await api('/api/players/ban', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
uuid: selectedUuid,
|
||||
username: selectedPlayerName(),
|
||||
reason: actionReason('Banned by panel moderator')
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('unbanButton').addEventListener('click', async () => {
|
||||
await runPlayerAction(async () => {
|
||||
await api('/api/players/unban', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uuid: selectedUuid, username: selectedPlayerName() })
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('muteButton').addEventListener('click', async () => {
|
||||
await runPlayerAction(async () => {
|
||||
await api('/api/extensions/player-management/mute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
uuid: selectedUuid,
|
||||
username: selectedPlayerName(),
|
||||
durationMinutes: actionDuration(),
|
||||
reason: actionReason('Muted by panel moderator')
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('unmuteButton').addEventListener('click', async () => {
|
||||
await runPlayerAction(async () => {
|
||||
await api('/api/extensions/player-management/unmute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uuid: selectedUuid, username: selectedPlayerName() })
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('airstrikeButton').addEventListener('click', async () => {
|
||||
if (!airstrikeAvailable) {
|
||||
alert('Airstrike extension is not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
await runPlayerAction(async () => {
|
||||
await api('/api/extensions/airstrike/launch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
uuid: selectedUuid,
|
||||
username: selectedPlayerName(),
|
||||
amount: actionTntAmount()
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('playerSearch').addEventListener('input', renderPlayerDirectory);
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await Promise.all([loadMe(), detectPlayerManagementExtension(), loadPlayerList()]);
|
||||
await detectPlayerStatsExtension();
|
||||
await detectLuckPermsExtension();
|
||||
await detectAirstrikeExtension();
|
||||
if (allPlayers.length > 0) {
|
||||
selectedUuid = allPlayers[0].uuid;
|
||||
renderPlayerDirectory();
|
||||
await loadPlayerProfile(selectedUuid);
|
||||
}
|
||||
setInterval(() => {
|
||||
loadPlayerList().catch(() => {});
|
||||
}, 5000);
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,368 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Plugins</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link active" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Plugins</h1>
|
||||
<p>Installed server plugins: <span id="pluginCount">0</span></p>
|
||||
|
||||
<div class="tabs">
|
||||
<button id="tabInstalled" class="tab-link active" type="button">Installed</button>
|
||||
<button id="tabInstall" class="tab-link" type="button">Install</button>
|
||||
</div>
|
||||
|
||||
<section id="installedSection" class="card spaced-top">
|
||||
<h2>Installed Plugins</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Version</th>
|
||||
<th>Enabled</th>
|
||||
<th>Main Class</th>
|
||||
<th>Authors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="plugins"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="installSection" class="card spaced-top" style="display:none;">
|
||||
<h2>Install From Marketplace</h2>
|
||||
<p>Search results are filtered to Paper-compatible plugins.</p>
|
||||
<div class="marketplace-search-row">
|
||||
<select id="marketSource">
|
||||
<option value="modrinth">Modrinth</option>
|
||||
<option value="curseforge">CurseForge</option>
|
||||
<option value="hangar">Hangar</option>
|
||||
</select>
|
||||
<input id="marketQuery" placeholder="Search Paper plugin name">
|
||||
<button id="searchMarketplace" class="secondary" type="button">Search</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap spaced-top">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Author</th>
|
||||
<th>Description</th>
|
||||
<th>Versions</th>
|
||||
<th>Install</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="marketResults"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="marketStatus" class="action-status"></p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const projectVersions = new Map();
|
||||
|
||||
async function api(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
function renderPluginRow(plugin) {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
const nameTd = document.createElement('td');
|
||||
nameTd.textContent = plugin.name || 'Unknown';
|
||||
tr.appendChild(nameTd);
|
||||
|
||||
const versionTd = document.createElement('td');
|
||||
versionTd.textContent = plugin.version || '-';
|
||||
tr.appendChild(versionTd);
|
||||
|
||||
const enabledTd = document.createElement('td');
|
||||
enabledTd.textContent = plugin.enabled ? 'Yes' : 'No';
|
||||
tr.appendChild(enabledTd);
|
||||
|
||||
const mainTd = document.createElement('td');
|
||||
mainTd.textContent = plugin.main || '-';
|
||||
tr.appendChild(mainTd);
|
||||
|
||||
const authorsTd = document.createElement('td');
|
||||
const authors = Array.isArray(plugin.authors) ? plugin.authors : [];
|
||||
authorsTd.textContent = authors.length > 0 ? authors.join(', ') : '-';
|
||||
tr.appendChild(authorsTd);
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
async function loadPlugins() {
|
||||
const data = await api('/api/plugins');
|
||||
document.getElementById('pluginCount').textContent = data.count ?? 0;
|
||||
|
||||
const tbody = document.getElementById('plugins');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (!Array.isArray(data.plugins) || data.plugins.length === 0) {
|
||||
const emptyRow = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 5;
|
||||
cell.textContent = 'No plugins found';
|
||||
emptyRow.appendChild(cell);
|
||||
tbody.appendChild(emptyRow);
|
||||
return;
|
||||
}
|
||||
|
||||
data.plugins.forEach(plugin => {
|
||||
tbody.appendChild(renderPluginRow(plugin));
|
||||
});
|
||||
}
|
||||
|
||||
function setTab(installedVisible) {
|
||||
document.getElementById('installedSection').style.display = installedVisible ? 'block' : 'none';
|
||||
document.getElementById('installSection').style.display = installedVisible ? 'none' : 'block';
|
||||
document.getElementById('tabInstalled').classList.toggle('active', installedVisible);
|
||||
document.getElementById('tabInstall').classList.toggle('active', !installedVisible);
|
||||
}
|
||||
|
||||
function setMarketStatus(message, isError) {
|
||||
const status = document.getElementById('marketStatus');
|
||||
status.textContent = message || '';
|
||||
status.className = isError ? 'action-status error-text' : 'action-status success-text';
|
||||
}
|
||||
|
||||
async function fetchVersionsForProject(source, projectId) {
|
||||
const key = `${source}:${projectId}`;
|
||||
if (projectVersions.has(key)) {
|
||||
return projectVersions.get(key);
|
||||
}
|
||||
|
||||
const data = await api(`/api/plugin-marketplace/versions?source=${encodeURIComponent(source)}&projectId=${encodeURIComponent(projectId)}`);
|
||||
const versions = Array.isArray(data.versions) ? data.versions : [];
|
||||
projectVersions.set(key, versions);
|
||||
return versions;
|
||||
}
|
||||
|
||||
function createMarketRow(source, project) {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
const nameTd = document.createElement('td');
|
||||
nameTd.textContent = project.name || 'Unknown';
|
||||
tr.appendChild(nameTd);
|
||||
|
||||
const authorTd = document.createElement('td');
|
||||
authorTd.textContent = project.author || '-';
|
||||
tr.appendChild(authorTd);
|
||||
|
||||
const descTd = document.createElement('td');
|
||||
descTd.textContent = project.description || '-';
|
||||
tr.appendChild(descTd);
|
||||
|
||||
const versionsTd = document.createElement('td');
|
||||
const versionSelect = document.createElement('select');
|
||||
versionSelect.disabled = true;
|
||||
versionsTd.appendChild(versionSelect);
|
||||
tr.appendChild(versionsTd);
|
||||
|
||||
const installTd = document.createElement('td');
|
||||
const installBtn = document.createElement('button');
|
||||
installBtn.textContent = 'Install';
|
||||
installBtn.disabled = true;
|
||||
installTd.appendChild(installBtn);
|
||||
tr.appendChild(installTd);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const versions = await fetchVersionsForProject(source, project.projectId);
|
||||
versionSelect.innerHTML = '';
|
||||
if (versions.length === 0) {
|
||||
const option = document.createElement('option');
|
||||
option.textContent = 'No versions';
|
||||
versionSelect.appendChild(option);
|
||||
return;
|
||||
}
|
||||
|
||||
versions.forEach(version => {
|
||||
const option = document.createElement('option');
|
||||
option.value = version.versionId;
|
||||
option.textContent = version.name || version.versionId;
|
||||
versionSelect.appendChild(option);
|
||||
});
|
||||
versionSelect.disabled = false;
|
||||
installBtn.disabled = false;
|
||||
} catch (error) {
|
||||
setMarketStatus(`Failed to load versions for ${project.name}: ${error.message}`, true);
|
||||
}
|
||||
})();
|
||||
|
||||
installBtn.addEventListener('click', async () => {
|
||||
if (!versionSelect.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
installBtn.disabled = true;
|
||||
const result = await api('/api/plugin-marketplace/install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source,
|
||||
projectId: project.projectId,
|
||||
versionId: versionSelect.value
|
||||
})
|
||||
});
|
||||
setMarketStatus(`Installed ${result.fileName}. Restart required to load the plugin.`, false);
|
||||
await loadPlugins();
|
||||
} catch (error) {
|
||||
setMarketStatus(`Install failed: ${error.message}`, true);
|
||||
} finally {
|
||||
installBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
async function searchMarketplace() {
|
||||
const source = document.getElementById('marketSource').value;
|
||||
const query = document.getElementById('marketQuery').value.trim();
|
||||
if (!query) {
|
||||
setMarketStatus('Enter a search term first', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await api(`/api/plugin-marketplace/search?source=${encodeURIComponent(source)}&query=${encodeURIComponent(query)}`);
|
||||
const results = Array.isArray(data.results) ? data.results : [];
|
||||
const tbody = document.getElementById('marketResults');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (results.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 5;
|
||||
cell.textContent = 'No marketplace results found';
|
||||
row.appendChild(cell);
|
||||
tbody.appendChild(row);
|
||||
setMarketStatus('', false);
|
||||
return;
|
||||
}
|
||||
|
||||
results.forEach(project => {
|
||||
tbody.appendChild(createMarketRow(source, project));
|
||||
});
|
||||
setMarketStatus(`Found ${results.length} result(s) in ${source}.`, false);
|
||||
}
|
||||
|
||||
document.getElementById('tabInstalled').addEventListener('click', () => setTab(true));
|
||||
document.getElementById('tabInstall').addEventListener('click', () => setTab(false));
|
||||
document.getElementById('searchMarketplace').addEventListener('click', async () => {
|
||||
try {
|
||||
await searchMarketplace();
|
||||
} catch (error) {
|
||||
setMarketStatus(`Search failed: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
document.getElementById('marketQuery').addEventListener('keydown', async event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await searchMarketplace();
|
||||
} catch (error) {
|
||||
setMarketStatus(`Search failed: ${error.message}`, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await Promise.all([loadMe(), loadPlugins()]);
|
||||
setInterval(() => {
|
||||
loadPlugins().catch(() => {});
|
||||
}, 5000);
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Reports</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
<a class="side-link active" href="/dashboard/reports">Reports</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Reports</h1>
|
||||
<div class="toolbar">
|
||||
<select id="reportStatusFilter" style="width:auto;min-width:180px;">
|
||||
<option value="">All statuses</option>
|
||||
<option value="OPEN">Open</option>
|
||||
<option value="RESOLVED">Resolved</option>
|
||||
</select>
|
||||
<button id="refreshReports" class="secondary">Refresh</button>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Player Reports</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Reporter</th>
|
||||
<th>Suspect</th>
|
||||
<th>Reason</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reportsBody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp || Number(timestamp) <= 0) return '-';
|
||||
return new Date(Number(timestamp)).toLocaleString();
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
const status = document.getElementById('reportStatusFilter').value;
|
||||
const url = status ? `/api/extensions/reports?status=${encodeURIComponent(status)}` : '/api/extensions/reports';
|
||||
const data = await api(url);
|
||||
|
||||
const body = document.getElementById('reportsBody');
|
||||
body.innerHTML = '';
|
||||
|
||||
if (!Array.isArray(data.reports) || data.reports.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 7;
|
||||
cell.textContent = 'No reports found';
|
||||
row.appendChild(cell);
|
||||
body.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
data.reports.forEach(report => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>#${report.id}</td>
|
||||
<td>${report.reporterName}</td>
|
||||
<td>${report.suspectName}</td>
|
||||
<td>${report.reason}</td>
|
||||
<td>${report.status}</td>
|
||||
<td>${formatDate(report.createdAt)}</td>
|
||||
<td></td>
|
||||
`;
|
||||
|
||||
const actionsCell = row.lastElementChild;
|
||||
if (report.status === 'OPEN') {
|
||||
const resolveButton = document.createElement('button');
|
||||
resolveButton.className = 'secondary';
|
||||
resolveButton.style.width = 'auto';
|
||||
resolveButton.textContent = 'Resolve';
|
||||
resolveButton.addEventListener('click', async () => {
|
||||
await api(`/api/extensions/reports/${report.id}/resolve`, { method: 'POST' });
|
||||
await loadReports();
|
||||
});
|
||||
|
||||
const banButton = document.createElement('button');
|
||||
banButton.style.width = 'auto';
|
||||
banButton.textContent = 'Ban 60m';
|
||||
banButton.addEventListener('click', async () => {
|
||||
await api(`/api/extensions/reports/${report.id}/ban`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ durationMinutes: 60, reason: `Auto-ban from report #${report.id}` })
|
||||
});
|
||||
await loadReports();
|
||||
});
|
||||
|
||||
actionsCell.appendChild(resolveButton);
|
||||
actionsCell.appendChild(document.createTextNode(' '));
|
||||
actionsCell.appendChild(banButton);
|
||||
} else {
|
||||
actionsCell.textContent = '-';
|
||||
}
|
||||
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('refreshReports').addEventListener('click', loadReports);
|
||||
document.getElementById('reportStatusFilter').addEventListener('change', loadReports);
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await loadMe();
|
||||
await loadReports();
|
||||
setInterval(() => loadReports().catch(() => {}), 5000);
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Resources</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link active" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Resources</h1>
|
||||
<section class="stats-grid spaced-top">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">CPU Load (System)</div>
|
||||
<div id="statCpuSystem" class="stat-value">0%</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">CPU Load (MinePanel Process)</div>
|
||||
<div id="statCpuProcess" class="stat-value">0%</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Memory Used</div>
|
||||
<div id="statMemory" class="stat-value">0 MB</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Disk Used</div>
|
||||
<div id="statDisk" class="stat-value">0 GB</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Server Runtime</h2>
|
||||
<pre id="runtimeInfo" class="log-rectangle short"></pre>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
function toPercent(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric < 0) {
|
||||
return 'Unavailable';
|
||||
}
|
||||
return `${Math.round(numeric * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function toMb(bytes) {
|
||||
return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
|
||||
}
|
||||
|
||||
function toGb(bytes) {
|
||||
return `${Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100} GB`;
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
const health = await api('/api/health');
|
||||
|
||||
document.getElementById('statCpuSystem').textContent = toPercent(health.cpuSystemLoad);
|
||||
document.getElementById('statCpuProcess').textContent = toPercent(health.cpuProcessLoad);
|
||||
document.getElementById('statMemory').textContent = `${toMb(health.memoryUsed || 0)} / ${toMb(health.memoryMax || 0)}`;
|
||||
|
||||
if ((health.diskUsed || -1) >= 0 && (health.diskTotal || -1) >= 0) {
|
||||
document.getElementById('statDisk').textContent = `${toGb(health.diskUsed)} / ${toGb(health.diskTotal)}`;
|
||||
} else {
|
||||
document.getElementById('statDisk').textContent = 'Unavailable';
|
||||
}
|
||||
|
||||
document.getElementById('runtimeInfo').textContent = [
|
||||
`Server: ${health.serverName || 'unknown'}`,
|
||||
`Version: ${health.serverVersion || 'unknown'}`,
|
||||
`Bukkit: ${health.bukkitVersion || 'unknown'}`,
|
||||
`CPU Cores: ${health.cpuCores || 0}`,
|
||||
`Memory Total: ${toMb(health.memoryTotal || 0)}`,
|
||||
`Memory Free: ${toMb((health.memoryTotal || 0) - (health.memoryUsed || 0))}`,
|
||||
`Disk Free: ${(health.diskFree || -1) >= 0 ? toGb(health.diskFree) : 'Unavailable'}`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await Promise.all([loadMe(), loadHealth()]);
|
||||
setInterval(() => {
|
||||
loadHealth().catch(() => {});
|
||||
}, 3000);
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Themes</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link active" href="/dashboard/themes">Themes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Themes</h1>
|
||||
<p>Pick a preset or customize panel colors.</p>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Presets</h2>
|
||||
<div class="theme-preset-row">
|
||||
<select id="themePreset">
|
||||
<option value="default">Default (MinePanel)</option>
|
||||
<option value="midnight">Midnight Blue</option>
|
||||
<option value="forest">Forest Dark</option>
|
||||
<option value="ember">Ember Dark</option>
|
||||
</select>
|
||||
<button id="applyPreset" type="button">Apply Preset</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Custom Colors</h2>
|
||||
<div id="themeGrid" class="theme-grid"></div>
|
||||
<div class="theme-actions">
|
||||
<button id="saveTheme" type="button">Save Custom Theme</button>
|
||||
<button id="resetTheme" class="secondary" type="button">Reset to Default</button>
|
||||
</div>
|
||||
<p id="themeStatus" class="action-status"></p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const STORAGE_KEY = window.MinePanelTheme ? window.MinePanelTheme.storageKey : 'minepanel.customTheme';
|
||||
const THEME_KEYS = [
|
||||
'--bg',
|
||||
'--surface',
|
||||
'--surface-2',
|
||||
'--border',
|
||||
'--text',
|
||||
'--text-muted',
|
||||
'--accent',
|
||||
'--accent-strong',
|
||||
'--danger-bg',
|
||||
'--danger-text',
|
||||
'--log-bg',
|
||||
'--sidebar-bg',
|
||||
'--sidebar-border',
|
||||
'--sidebar-meta-bg',
|
||||
'--sidebar-meta-border',
|
||||
'--sidebar-toggle-text',
|
||||
'--sidebar-toggle-icon',
|
||||
'--sidebar-link-text',
|
||||
'--sidebar-link-bg',
|
||||
'--sidebar-link-border',
|
||||
'--sidebar-link-hover-bg',
|
||||
'--button-bg',
|
||||
'--button-border',
|
||||
'--button-hover-bg',
|
||||
'--button-secondary-bg',
|
||||
'--button-secondary-border',
|
||||
'--input-bg',
|
||||
'--focus-ring',
|
||||
'--table-bg',
|
||||
'--table-head-bg',
|
||||
'--table-row-border',
|
||||
'--table-row-hover-bg',
|
||||
'--table-head-text',
|
||||
'--table-cell-text',
|
||||
'--log-border',
|
||||
'--log-text',
|
||||
'--chart-bg',
|
||||
'--chart-border',
|
||||
'--chart-grid',
|
||||
'--chart-label',
|
||||
'--player-box-bg',
|
||||
'--player-box-border',
|
||||
'--player-box-hover-bg',
|
||||
'--player-box-active-bg',
|
||||
'--player-box-active-border',
|
||||
'--player-muted-bg',
|
||||
'--player-muted-border'
|
||||
];
|
||||
|
||||
const THEME_GROUPS = [
|
||||
{
|
||||
title: 'Core',
|
||||
keys: [
|
||||
'--bg',
|
||||
'--surface',
|
||||
'--surface-2',
|
||||
'--border',
|
||||
'--text',
|
||||
'--text-muted',
|
||||
'--accent',
|
||||
'--accent-strong',
|
||||
'--danger-bg',
|
||||
'--danger-text'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Sidebar',
|
||||
keys: [
|
||||
'--sidebar-bg',
|
||||
'--sidebar-border',
|
||||
'--sidebar-meta-bg',
|
||||
'--sidebar-meta-border',
|
||||
'--sidebar-toggle-text',
|
||||
'--sidebar-toggle-icon',
|
||||
'--sidebar-link-text',
|
||||
'--sidebar-link-bg',
|
||||
'--sidebar-link-border',
|
||||
'--sidebar-link-hover-bg'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Buttons and Inputs',
|
||||
keys: [
|
||||
'--button-bg',
|
||||
'--button-border',
|
||||
'--button-hover-bg',
|
||||
'--button-secondary-bg',
|
||||
'--button-secondary-border',
|
||||
'--input-bg',
|
||||
'--focus-ring'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Logs and Tables',
|
||||
keys: [
|
||||
'--log-bg',
|
||||
'--log-border',
|
||||
'--log-text',
|
||||
'--table-bg',
|
||||
'--table-head-bg',
|
||||
'--table-row-border',
|
||||
'--table-row-hover-bg',
|
||||
'--table-head-text',
|
||||
'--table-cell-text'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Charts',
|
||||
keys: [
|
||||
'--chart-bg',
|
||||
'--chart-border',
|
||||
'--chart-grid',
|
||||
'--chart-label'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Players',
|
||||
keys: [
|
||||
'--player-box-bg',
|
||||
'--player-box-border',
|
||||
'--player-box-hover-bg',
|
||||
'--player-box-active-bg',
|
||||
'--player-box-active-border',
|
||||
'--player-muted-bg',
|
||||
'--player-muted-border'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const PRESETS = {
|
||||
default: {
|
||||
'--bg': '#0b1220',
|
||||
'--surface': '#111a2e',
|
||||
'--surface-2': '#16233d',
|
||||
'--border': '#24324f',
|
||||
'--text': '#e5ebf8',
|
||||
'--text-muted': '#99a8c6',
|
||||
'--accent': '#4f8cff',
|
||||
'--accent-strong': '#3a75e8',
|
||||
'--danger-bg': '#3d1d29',
|
||||
'--danger-text': '#ffb7c7',
|
||||
'--log-bg': '#0a101d',
|
||||
'--sidebar-bg': '#060b16',
|
||||
'--sidebar-border': '#1b2942',
|
||||
'--sidebar-meta-bg': '#0d1528',
|
||||
'--sidebar-meta-border': '#1f3050',
|
||||
'--sidebar-toggle-text': '#7f93b8',
|
||||
'--sidebar-toggle-icon': '#8fa6d0',
|
||||
'--sidebar-link-text': '#c7d7f7',
|
||||
'--sidebar-link-bg': '#0f1930',
|
||||
'--sidebar-link-border': '#263a5d',
|
||||
'--sidebar-link-hover-bg': '#162642',
|
||||
'--button-bg': '#4f8cff',
|
||||
'--button-border': '#3a75e8',
|
||||
'--button-hover-bg': '#5a95ff',
|
||||
'--button-secondary-bg': '#21314d',
|
||||
'--button-secondary-border': '#344769',
|
||||
'--input-bg': '#0f1930',
|
||||
'--focus-ring': '#4f8cff',
|
||||
'--table-bg': '#0e172b',
|
||||
'--table-head-bg': '#13203b',
|
||||
'--table-row-border': '#1f2d49',
|
||||
'--table-row-hover-bg': '#111d35',
|
||||
'--table-head-text': '#d7e2f8',
|
||||
'--table-cell-text': '#c7d4ee',
|
||||
'--log-border': '#213457',
|
||||
'--log-text': '#d6e0f8',
|
||||
'--chart-bg': '#0b1427',
|
||||
'--chart-border': '#213457',
|
||||
'--chart-grid': '#7896d2',
|
||||
'--chart-label': '#8ea6d2',
|
||||
'--player-box-bg': '#0f1930',
|
||||
'--player-box-border': '#23365a',
|
||||
'--player-box-hover-bg': '#162642',
|
||||
'--player-box-active-bg': '#1b2c4a',
|
||||
'--player-box-active-border': '#3f73cf',
|
||||
'--player-muted-bg': '#101a2f',
|
||||
'--player-muted-border': '#2a3f63'
|
||||
},
|
||||
midnight: {
|
||||
'--bg': '#070b17',
|
||||
'--surface': '#0f1a30',
|
||||
'--surface-2': '#132445',
|
||||
'--border': '#263f67',
|
||||
'--text': '#e6f0ff',
|
||||
'--text-muted': '#97abd2',
|
||||
'--accent': '#4f8cff',
|
||||
'--accent-strong': '#2c6fe2',
|
||||
'--danger-bg': '#3b1a2f',
|
||||
'--danger-text': '#ffb5c6',
|
||||
'--log-bg': '#090f1d',
|
||||
'--sidebar-bg': '#050915',
|
||||
'--sidebar-border': '#1b3158',
|
||||
'--sidebar-meta-bg': '#0c1730',
|
||||
'--sidebar-meta-border': '#1f3a67',
|
||||
'--sidebar-toggle-text': '#8aa1ca',
|
||||
'--sidebar-toggle-icon': '#9cb4e2',
|
||||
'--sidebar-link-text': '#d2e2ff',
|
||||
'--sidebar-link-bg': '#11203b',
|
||||
'--sidebar-link-border': '#2b4773',
|
||||
'--sidebar-link-hover-bg': '#183055',
|
||||
'--button-bg': '#4f8cff',
|
||||
'--button-border': '#2c6fe2',
|
||||
'--button-hover-bg': '#5f98ff',
|
||||
'--button-secondary-bg': '#23385e',
|
||||
'--button-secondary-border': '#35507e',
|
||||
'--input-bg': '#11203b',
|
||||
'--focus-ring': '#4f8cff',
|
||||
'--table-bg': '#0f1a31',
|
||||
'--table-head-bg': '#13274a',
|
||||
'--table-row-border': '#2a4773',
|
||||
'--table-row-hover-bg': '#183157',
|
||||
'--table-head-text': '#d7e6ff',
|
||||
'--table-cell-text': '#cddcff',
|
||||
'--log-border': '#2b4a78',
|
||||
'--log-text': '#d4e3ff',
|
||||
'--chart-bg': '#0f1d38',
|
||||
'--chart-border': '#2b4a78',
|
||||
'--chart-grid': '#87a7df',
|
||||
'--chart-label': '#a3b8df',
|
||||
'--player-box-bg': '#11203b',
|
||||
'--player-box-border': '#2b4773',
|
||||
'--player-box-hover-bg': '#183055',
|
||||
'--player-box-active-bg': '#223c69',
|
||||
'--player-box-active-border': '#4b82de',
|
||||
'--player-muted-bg': '#0f1b34',
|
||||
'--player-muted-border': '#2a4268'
|
||||
},
|
||||
forest: {
|
||||
'--bg': '#0b1612',
|
||||
'--surface': '#11231b',
|
||||
'--surface-2': '#173026',
|
||||
'--border': '#29503f',
|
||||
'--text': '#e7fff5',
|
||||
'--text-muted': '#97c7b0',
|
||||
'--accent': '#3fbf7f',
|
||||
'--accent-strong': '#2ca866',
|
||||
'--danger-bg': '#3d1d29',
|
||||
'--danger-text': '#ffb7c7',
|
||||
'--log-bg': '#0b1511',
|
||||
'--sidebar-bg': '#08130f',
|
||||
'--sidebar-border': '#1f3f33',
|
||||
'--sidebar-meta-bg': '#10231c',
|
||||
'--sidebar-meta-border': '#2a4f40',
|
||||
'--sidebar-toggle-text': '#8db8a3',
|
||||
'--sidebar-toggle-icon': '#9fceb8',
|
||||
'--sidebar-link-text': '#d6f5e7',
|
||||
'--sidebar-link-bg': '#133026',
|
||||
'--sidebar-link-border': '#2f5f4b',
|
||||
'--sidebar-link-hover-bg': '#1a3d31',
|
||||
'--button-bg': '#3fbf7f',
|
||||
'--button-border': '#2ca866',
|
||||
'--button-hover-bg': '#4dce8d',
|
||||
'--button-secondary-bg': '#24493b',
|
||||
'--button-secondary-border': '#356652',
|
||||
'--input-bg': '#133026',
|
||||
'--focus-ring': '#3fbf7f',
|
||||
'--table-bg': '#11271f',
|
||||
'--table-head-bg': '#173629',
|
||||
'--table-row-border': '#2e5b48',
|
||||
'--table-row-hover-bg': '#1a3f31',
|
||||
'--table-head-text': '#daf5e8',
|
||||
'--table-cell-text': '#c8ebda',
|
||||
'--log-border': '#2e5a49',
|
||||
'--log-text': '#d9f4e8',
|
||||
'--chart-bg': '#10261e',
|
||||
'--chart-border': '#2e5a49',
|
||||
'--chart-grid': '#7fb6a1',
|
||||
'--chart-label': '#94c7b3',
|
||||
'--player-box-bg': '#133026',
|
||||
'--player-box-border': '#2f5f4b',
|
||||
'--player-box-hover-bg': '#1a3d31',
|
||||
'--player-box-active-bg': '#23503f',
|
||||
'--player-box-active-border': '#43a379',
|
||||
'--player-muted-bg': '#11271f',
|
||||
'--player-muted-border': '#2c5342'
|
||||
},
|
||||
ember: {
|
||||
'--bg': '#18100b',
|
||||
'--surface': '#2a1a11',
|
||||
'--surface-2': '#3a2416',
|
||||
'--border': '#5a3a23',
|
||||
'--text': '#fff0e8',
|
||||
'--text-muted': '#d6b8a4',
|
||||
'--accent': '#ff8f4f',
|
||||
'--accent-strong': '#e67839',
|
||||
'--danger-bg': '#4a1f22',
|
||||
'--danger-text': '#ffb7b7',
|
||||
'--log-bg': '#1a120d',
|
||||
'--sidebar-bg': '#140d08',
|
||||
'--sidebar-border': '#4c311f',
|
||||
'--sidebar-meta-bg': '#23160e',
|
||||
'--sidebar-meta-border': '#5e3b26',
|
||||
'--sidebar-toggle-text': '#d1a78f',
|
||||
'--sidebar-toggle-icon': '#e0b69e',
|
||||
'--sidebar-link-text': '#ffe4d2',
|
||||
'--sidebar-link-bg': '#382116',
|
||||
'--sidebar-link-border': '#6f4228',
|
||||
'--sidebar-link-hover-bg': '#4a2c1d',
|
||||
'--button-bg': '#ff8f4f',
|
||||
'--button-border': '#e67839',
|
||||
'--button-hover-bg': '#ffa366',
|
||||
'--button-secondary-bg': '#5a3622',
|
||||
'--button-secondary-border': '#7a4a30',
|
||||
'--input-bg': '#382116',
|
||||
'--focus-ring': '#ff8f4f',
|
||||
'--table-bg': '#2f1d13',
|
||||
'--table-head-bg': '#432719',
|
||||
'--table-row-border': '#6b4027',
|
||||
'--table-row-hover-bg': '#4b2e1f',
|
||||
'--table-head-text': '#ffe7da',
|
||||
'--table-cell-text': '#f2d2bf',
|
||||
'--log-border': '#70442a',
|
||||
'--log-text': '#ffe4d5',
|
||||
'--chart-bg': '#341f14',
|
||||
'--chart-border': '#70442a',
|
||||
'--chart-grid': '#c18e71',
|
||||
'--chart-label': '#d2a98f',
|
||||
'--player-box-bg': '#382116',
|
||||
'--player-box-border': '#6f4228',
|
||||
'--player-box-hover-bg': '#4a2c1d',
|
||||
'--player-box-active-bg': '#603924',
|
||||
'--player-box-active-border': '#ff9a63',
|
||||
'--player-muted-bg': '#311d12',
|
||||
'--player-muted-border': '#5b3521'
|
||||
}
|
||||
};
|
||||
|
||||
function setStatus(message, isError) {
|
||||
const status = document.getElementById('themeStatus');
|
||||
status.textContent = message || '';
|
||||
status.className = isError ? 'action-status error-text' : 'action-status success-text';
|
||||
}
|
||||
|
||||
function currentValueForKey(key) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(key).trim();
|
||||
return value || PRESETS.default[key] || '#000000';
|
||||
}
|
||||
|
||||
function normalizeHex(value) {
|
||||
if (!value) return '#000000';
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith('#')) return '#000000';
|
||||
if (trimmed.length === 7) return trimmed;
|
||||
if (trimmed.length === 4) {
|
||||
return `#${trimmed[1]}${trimmed[1]}${trimmed[2]}${trimmed[2]}${trimmed[3]}${trimmed[3]}`;
|
||||
}
|
||||
return '#000000';
|
||||
}
|
||||
|
||||
function buildThemeField(key) {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'theme-field';
|
||||
row.setAttribute('for', `theme-${key.replace(/[^a-z0-9]/gi, '')}`);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.textContent = key;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'color';
|
||||
input.id = `theme-${key.replace(/[^a-z0-9]/gi, '')}`;
|
||||
input.dataset.themeKey = key;
|
||||
input.value = normalizeHex(currentValueForKey(key));
|
||||
input.addEventListener('input', () => {
|
||||
document.documentElement.style.setProperty(key, input.value);
|
||||
});
|
||||
|
||||
row.appendChild(name);
|
||||
row.appendChild(input);
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildThemeGroup(title, keys) {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'theme-group';
|
||||
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'theme-group-title';
|
||||
heading.textContent = title;
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'theme-group-grid';
|
||||
|
||||
keys.forEach(key => {
|
||||
content.appendChild(buildThemeField(key));
|
||||
});
|
||||
|
||||
section.appendChild(heading);
|
||||
section.appendChild(content);
|
||||
return section;
|
||||
}
|
||||
|
||||
function buildThemeGrid() {
|
||||
const grid = document.getElementById('themeGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
const usedKeys = new Set();
|
||||
THEME_GROUPS.forEach(group => {
|
||||
const validKeys = group.keys.filter(key => THEME_KEYS.includes(key));
|
||||
if (validKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
validKeys.forEach(key => usedKeys.add(key));
|
||||
grid.appendChild(buildThemeGroup(group.title, validKeys));
|
||||
});
|
||||
|
||||
const ungrouped = THEME_KEYS.filter(key => !usedKeys.has(key));
|
||||
if (ungrouped.length > 0) {
|
||||
grid.appendChild(buildThemeGroup('Other', ungrouped));
|
||||
}
|
||||
}
|
||||
|
||||
function collectThemeFromInputs() {
|
||||
const theme = {};
|
||||
document.querySelectorAll('input[data-theme-key]').forEach(input => {
|
||||
theme[input.dataset.themeKey] = input.value;
|
||||
});
|
||||
return theme;
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (window.MinePanelTheme) {
|
||||
window.MinePanelTheme.applyTheme(theme);
|
||||
} else {
|
||||
Object.entries(theme).forEach(([key, value]) => {
|
||||
document.documentElement.style.setProperty(key, value);
|
||||
});
|
||||
}
|
||||
buildThemeGrid();
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
document.getElementById('applyPreset').addEventListener('click', () => {
|
||||
const preset = document.getElementById('themePreset').value;
|
||||
const theme = PRESETS[preset] || PRESETS.default;
|
||||
applyTheme(theme);
|
||||
setStatus(`Applied ${preset} preset. Save to keep it.`, false);
|
||||
});
|
||||
|
||||
document.getElementById('saveTheme').addEventListener('click', () => {
|
||||
const theme = collectThemeFromInputs();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(theme));
|
||||
setStatus('Custom theme saved.', false);
|
||||
});
|
||||
|
||||
document.getElementById('resetTheme').addEventListener('click', () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
applyTheme(PRESETS.default);
|
||||
setStatus('Theme reset to default.', false);
|
||||
});
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await loadMe();
|
||||
buildThemeGrid();
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Tickets</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
<a class="side-link" href="/dashboard/reports">Reports</a>
|
||||
<a class="side-link active" href="/dashboard/tickets">Tickets</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Tickets</h1>
|
||||
<div class="toolbar">
|
||||
<select id="ticketStatusFilter" style="width:auto;min-width:180px;">
|
||||
<option value="">All statuses</option>
|
||||
<option value="OPEN">Open</option>
|
||||
<option value="CLOSED">Closed</option>
|
||||
</select>
|
||||
<button id="refreshTickets" class="secondary">Refresh</button>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Player Tickets</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Player</th>
|
||||
<th>Category</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Handled By</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ticketsBody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp || Number(timestamp) <= 0) return '-';
|
||||
return new Date(Number(timestamp)).toLocaleString();
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
const status = document.getElementById('ticketStatusFilter').value;
|
||||
const url = status ? `/api/extensions/tickets?status=${encodeURIComponent(status)}` : '/api/extensions/tickets';
|
||||
const data = await api(url);
|
||||
|
||||
const body = document.getElementById('ticketsBody');
|
||||
body.innerHTML = '';
|
||||
|
||||
if (!Array.isArray(data.tickets) || data.tickets.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 8;
|
||||
cell.textContent = 'No tickets found';
|
||||
row.appendChild(cell);
|
||||
body.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
data.tickets.forEach(ticket => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>#${ticket.id}</td>
|
||||
<td>${ticket.creatorName}</td>
|
||||
<td>${ticket.category}</td>
|
||||
<td>${ticket.description}</td>
|
||||
<td>${ticket.status}</td>
|
||||
<td>${formatDate(ticket.createdAt)}</td>
|
||||
<td>${ticket.handledBy || '-'}</td>
|
||||
<td></td>
|
||||
`;
|
||||
|
||||
const actionsCell = row.lastElementChild;
|
||||
if (ticket.status === 'OPEN') {
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.className = 'secondary';
|
||||
closeButton.style.width = 'auto';
|
||||
closeButton.textContent = 'Close';
|
||||
closeButton.addEventListener('click', async () => {
|
||||
await api(`/api/extensions/tickets/${ticket.id}/close`, { method: 'POST' });
|
||||
await loadTickets();
|
||||
});
|
||||
actionsCell.appendChild(closeButton);
|
||||
} else {
|
||||
const reopenButton = document.createElement('button');
|
||||
reopenButton.style.width = 'auto';
|
||||
reopenButton.textContent = 'Reopen';
|
||||
reopenButton.addEventListener('click', async () => {
|
||||
await api(`/api/extensions/tickets/${ticket.id}/reopen`, { method: 'POST' });
|
||||
await loadTickets();
|
||||
});
|
||||
actionsCell.appendChild(reopenButton);
|
||||
}
|
||||
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('refreshTickets').addEventListener('click', loadTickets);
|
||||
document.getElementById('ticketStatusFilter').addEventListener('change', loadTickets);
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await loadMe();
|
||||
await loadTickets();
|
||||
setInterval(() => loadTickets().catch(() => {}), 5000);
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Users</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
<style>
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.permissions-shell {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.permission-group {
|
||||
border: 1px solid var(--theme-border, rgba(255, 255, 255, 0.14));
|
||||
border-radius: 10px;
|
||||
background: var(--theme-surface-2, rgba(16, 20, 30, 0.82));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.permission-group details {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.permission-group-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.permission-group-toggle::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.permission-group.open .permission-group-toggle {
|
||||
border-bottom-color: var(--theme-border, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
|
||||
.permission-group-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.permission-group-meta {
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.permission-group-chevron {
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.permission-group.open .permission-group-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.permission-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.permission-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--theme-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 8px;
|
||||
padding: 7px 9px;
|
||||
background: var(--theme-surface-3, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
.permission-row input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.permission-row.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link active" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Users</h1>
|
||||
<section class="card spaced-top">
|
||||
<h2>User Management</h2>
|
||||
<div class="inline-form">
|
||||
<button id="openCreateWizard" type="button">New User</button>
|
||||
</div>
|
||||
|
||||
<div id="usersMainContent" class="spaced-top">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Username</th><th>Role</th><th>Save Role</th><th>Permissions</th><th>Delete</th></tr></thead>
|
||||
<tbody id="users"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="permissionEditor" class="spaced-top" style="display:none;">
|
||||
<h3 id="permissionEditorTitle">Edit permissions</h3>
|
||||
<div id="permissionEditorGroups"></div>
|
||||
<div class="spaced-top">
|
||||
<button id="savePermissions">Save Permissions</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="createUserWizard" class="spaced-top" style="display:none;">
|
||||
<h3 id="createWizardTitle">Create User - Step 1 of 3</h3>
|
||||
|
||||
<div id="wizardStep1" class="card spaced-top">
|
||||
<label for="wizardUsername">Username</label>
|
||||
<input id="wizardUsername" placeholder="Choose username">
|
||||
</div>
|
||||
|
||||
<div id="wizardStep2" class="card spaced-top" style="display:none;">
|
||||
<label for="wizardPassword">Password</label>
|
||||
<input id="wizardPassword" type="password" placeholder="Choose password (min. 10 chars)">
|
||||
</div>
|
||||
|
||||
<div id="wizardStep3" class="card spaced-top" style="display:none;">
|
||||
<label for="wizardRole">Role Preset</label>
|
||||
<select id="wizardRole">
|
||||
<option>VIEWER</option>
|
||||
<option>ADMIN</option>
|
||||
</select>
|
||||
<div id="wizardPermissionGroups" class="spaced-top"></div>
|
||||
</div>
|
||||
|
||||
<div class="inline-form spaced-top">
|
||||
<button id="wizardCancel" type="button" class="secondary">Cancel</button>
|
||||
<button id="wizardBack" type="button" class="secondary">Back</button>
|
||||
<button id="wizardNext" type="button">Next</button>
|
||||
<button id="wizardCreate" type="button" style="display:none;">Create User</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="userStatus" class="action-status"></p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let permissionCatalog = [];
|
||||
let roleDefaults = {};
|
||||
let usersById = new Map();
|
||||
let selectedUserId = null;
|
||||
const permissionGroupStateKey = 'minepanel.permissions.groupState';
|
||||
const createWizardState = {
|
||||
step: 1,
|
||||
username: '',
|
||||
password: '',
|
||||
role: 'VIEWER',
|
||||
permissions: []
|
||||
};
|
||||
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
function setStatus(message, isError) {
|
||||
const status = document.getElementById('userStatus');
|
||||
status.textContent = message || '';
|
||||
status.className = isError ? 'action-status error-text' : 'action-status success-text';
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
function groupedPermissions() {
|
||||
const grouped = new Map();
|
||||
permissionCatalog.forEach(permission => {
|
||||
const category = permission.category || 'Other';
|
||||
if (!grouped.has(category)) grouped.set(category, []);
|
||||
grouped.get(category).push(permission);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function loadPermissionGroupState() {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(permissionGroupStateKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function savePermissionGroupState(state) {
|
||||
try {
|
||||
sessionStorage.setItem(permissionGroupStateKey, JSON.stringify(state));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
function renderPermissionCheckboxes(container, selectedPermissions, prefix, disabled) {
|
||||
const groups = groupedPermissions();
|
||||
const state = loadPermissionGroupState();
|
||||
container.innerHTML = '';
|
||||
container.classList.add('permissions-shell');
|
||||
|
||||
let groupIndex = 0;
|
||||
|
||||
groups.forEach((items, category) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'permission-group';
|
||||
|
||||
const groupId = `${prefix}:${category}`;
|
||||
const hasSavedState = Object.prototype.hasOwnProperty.call(state, groupId);
|
||||
const isOpen = hasSavedState ? state[groupId] === true : groupIndex === 0;
|
||||
|
||||
const details = document.createElement('details');
|
||||
details.open = isOpen;
|
||||
details.addEventListener('toggle', () => {
|
||||
state[groupId] = details.open;
|
||||
savePermissionGroupState(state);
|
||||
card.classList.toggle('open', details.open);
|
||||
});
|
||||
|
||||
if (details.open) {
|
||||
card.classList.add('open');
|
||||
}
|
||||
|
||||
const toggle = document.createElement('summary');
|
||||
toggle.className = 'permission-group-toggle';
|
||||
toggle.innerHTML = `
|
||||
<span>
|
||||
<span class="permission-group-title">${category}</span>
|
||||
<span class="permission-group-meta">${items.length} permission${items.length === 1 ? '' : 's'}</span>
|
||||
</span>
|
||||
<span class="permission-group-chevron">▼</span>
|
||||
`;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'permission-grid';
|
||||
|
||||
items.forEach(permission => {
|
||||
const id = `${prefix}_${permission.key}`;
|
||||
const checked = selectedPermissions.has(permission.key) ? 'checked' : '';
|
||||
const lock = disabled ? 'disabled' : '';
|
||||
const row = document.createElement('label');
|
||||
row.className = `permission-row${disabled ? ' disabled' : ''}`;
|
||||
row.innerHTML = `<input type="checkbox" id="${id}" data-permission="${permission.key}" ${checked} ${lock}><span>${permission.label}</span>`;
|
||||
wrapper.appendChild(row);
|
||||
});
|
||||
|
||||
details.appendChild(toggle);
|
||||
details.appendChild(wrapper);
|
||||
card.appendChild(details);
|
||||
container.appendChild(card);
|
||||
groupIndex++;
|
||||
});
|
||||
}
|
||||
|
||||
function selectedPermissionsFromContainer(container) {
|
||||
const selected = [];
|
||||
container.querySelectorAll('input[type="checkbox"][data-permission]').forEach(input => {
|
||||
if (input.checked) selected.push(input.getAttribute('data-permission'));
|
||||
});
|
||||
return selected;
|
||||
}
|
||||
|
||||
function defaultPermissionsForRole(role) {
|
||||
return new Set(Array.isArray(roleDefaults[role]) ? roleDefaults[role] : []);
|
||||
}
|
||||
|
||||
function setCreateWizardVisible(visible) {
|
||||
document.getElementById('usersMainContent').style.display = visible ? 'none' : '';
|
||||
document.getElementById('createUserWizard').style.display = visible ? '' : 'none';
|
||||
}
|
||||
|
||||
function resetCreateWizard() {
|
||||
createWizardState.step = 1;
|
||||
createWizardState.username = '';
|
||||
createWizardState.password = '';
|
||||
createWizardState.role = 'VIEWER';
|
||||
createWizardState.permissions = [];
|
||||
document.getElementById('wizardUsername').value = '';
|
||||
document.getElementById('wizardPassword').value = '';
|
||||
document.getElementById('wizardRole').value = 'VIEWER';
|
||||
renderCreateWizardStep();
|
||||
}
|
||||
|
||||
function captureWizardPermissions() {
|
||||
const groups = document.getElementById('wizardPermissionGroups');
|
||||
if (groups && groups.children.length > 0) {
|
||||
createWizardState.permissions = selectedPermissionsFromContainer(groups);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCreateWizardPermissions(useRoleDefaults) {
|
||||
if (useRoleDefaults || !Array.isArray(createWizardState.permissions) || createWizardState.permissions.length === 0) {
|
||||
createWizardState.permissions = Array.from(defaultPermissionsForRole(createWizardState.role));
|
||||
}
|
||||
|
||||
renderPermissionCheckboxes(
|
||||
document.getElementById('wizardPermissionGroups'),
|
||||
new Set(createWizardState.permissions),
|
||||
'create_wizard',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
function renderCreateWizardStep() {
|
||||
const step = createWizardState.step;
|
||||
document.getElementById('createWizardTitle').textContent = `Create User - Step ${step} of 3`;
|
||||
|
||||
document.getElementById('wizardStep1').style.display = step === 1 ? '' : 'none';
|
||||
document.getElementById('wizardStep2').style.display = step === 2 ? '' : 'none';
|
||||
document.getElementById('wizardStep3').style.display = step === 3 ? '' : 'none';
|
||||
|
||||
document.getElementById('wizardBack').style.display = step === 1 ? 'none' : '';
|
||||
document.getElementById('wizardNext').style.display = step === 3 ? 'none' : '';
|
||||
document.getElementById('wizardCreate').style.display = step === 3 ? '' : 'none';
|
||||
|
||||
if (step === 1) {
|
||||
document.getElementById('wizardUsername').value = createWizardState.username;
|
||||
} else if (step === 2) {
|
||||
document.getElementById('wizardPassword').value = createWizardState.password;
|
||||
} else if (step === 3) {
|
||||
document.getElementById('wizardRole').value = createWizardState.role;
|
||||
renderCreateWizardPermissions(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const data = await api('/api/users');
|
||||
permissionCatalog = Array.isArray(data.permissions) ? data.permissions : [];
|
||||
roleDefaults = data.roleDefaults || {};
|
||||
|
||||
usersById = new Map();
|
||||
(Array.isArray(data.users) ? data.users : []).forEach(user => usersById.set(String(user.id), user));
|
||||
|
||||
|
||||
const tbody = document.getElementById('users');
|
||||
tbody.innerHTML = '';
|
||||
data.users.forEach(user => {
|
||||
const tr = document.createElement('tr');
|
||||
const ownerLocked = !!user.isOwner;
|
||||
tr.innerHTML = `<td>${user.id}</td><td>${user.username}</td><td>
|
||||
<select data-id="${user.id}" ${ownerLocked ? 'disabled' : ''}>
|
||||
${['VIEWER','ADMIN'].map(r => `<option ${user.role === r ? 'selected' : ''}>${r}</option>`).join('')}
|
||||
</select>
|
||||
</td><td><button data-save="${user.id}">Save</button></td>
|
||||
<td><span class="muted">-</span></td>
|
||||
<td><button data-delete="${user.id}" ${ownerLocked ? 'disabled title="Owner users cannot be deleted"' : ''}>Delete</button></td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('button[data-save]').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const id = button.getAttribute('data-save');
|
||||
const user = usersById.get(id);
|
||||
if (user && user.isOwner) {
|
||||
setStatus('Owner role cannot be changed.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const role = tbody.querySelector(`select[data-id="${id}"]`).value;
|
||||
try {
|
||||
await api(`/api/users/${id}/role`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role })
|
||||
});
|
||||
await loadUsers();
|
||||
setStatus('User role updated successfully.', false);
|
||||
} catch (error) {
|
||||
setStatus(`Failed to update user role: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('tr').forEach(row => {
|
||||
const id = row.querySelector('button[data-save]')?.getAttribute('data-save');
|
||||
const user = id ? usersById.get(id) : null;
|
||||
const permissionsCell = row.children[4];
|
||||
if (!permissionsCell || !user) return;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = user.isOwner ? 'Owner (all permissions)' : 'Edit Permissions';
|
||||
button.disabled = !!user.isOwner;
|
||||
button.addEventListener('click', () => openPermissionEditor(user));
|
||||
permissionsCell.innerHTML = '';
|
||||
permissionsCell.appendChild(button);
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('button[data-delete]').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const id = button.getAttribute('data-delete');
|
||||
const row = button.closest('tr');
|
||||
const username = row ? row.children[1].textContent : `ID ${id}`;
|
||||
if (!confirm(`Delete panel user '${username}'?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api(`/api/users/${id}/delete`, {
|
||||
method: 'POST'
|
||||
});
|
||||
await loadUsers();
|
||||
setStatus(`Deleted user '${username}'.`, false);
|
||||
} catch (error) {
|
||||
setStatus(`Failed to delete user '${username}': ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openPermissionEditor(user) {
|
||||
selectedUserId = String(user.id);
|
||||
const editor = document.getElementById('permissionEditor');
|
||||
const title = document.getElementById('permissionEditorTitle');
|
||||
const groups = document.getElementById('permissionEditorGroups');
|
||||
|
||||
title.textContent = `Permissions: ${user.username}`;
|
||||
const selected = new Set(Array.isArray(user.permissions) ? user.permissions : []);
|
||||
renderPermissionCheckboxes(groups, selected, `edit_${user.id}`, false);
|
||||
editor.style.display = 'block';
|
||||
}
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
try {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
setStatus(`Logout failed: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('openCreateWizard').addEventListener('click', () => {
|
||||
resetCreateWizard();
|
||||
setCreateWizardVisible(true);
|
||||
});
|
||||
|
||||
document.getElementById('wizardCancel').addEventListener('click', () => {
|
||||
captureWizardPermissions();
|
||||
setCreateWizardVisible(false);
|
||||
setStatus('', false);
|
||||
});
|
||||
|
||||
document.getElementById('wizardBack').addEventListener('click', () => {
|
||||
if (createWizardState.step === 3) {
|
||||
captureWizardPermissions();
|
||||
}
|
||||
createWizardState.step = Math.max(1, createWizardState.step - 1);
|
||||
renderCreateWizardStep();
|
||||
});
|
||||
|
||||
document.getElementById('wizardNext').addEventListener('click', () => {
|
||||
if (createWizardState.step === 1) {
|
||||
const username = document.getElementById('wizardUsername').value.trim();
|
||||
if (username.length < 3) {
|
||||
setStatus('Username must be at least 3 characters.', true);
|
||||
return;
|
||||
}
|
||||
createWizardState.username = username;
|
||||
} else if (createWizardState.step === 2) {
|
||||
const password = document.getElementById('wizardPassword').value;
|
||||
if (password.length < 10) {
|
||||
setStatus('Password must be at least 10 characters.', true);
|
||||
return;
|
||||
}
|
||||
createWizardState.password = password;
|
||||
createWizardState.permissions = Array.from(defaultPermissionsForRole(createWizardState.role));
|
||||
}
|
||||
|
||||
createWizardState.step = Math.min(3, createWizardState.step + 1);
|
||||
renderCreateWizardStep();
|
||||
setStatus('', false);
|
||||
});
|
||||
|
||||
document.getElementById('wizardRole').addEventListener('change', (event) => {
|
||||
createWizardState.role = event.target.value;
|
||||
renderCreateWizardPermissions(true);
|
||||
});
|
||||
|
||||
document.getElementById('wizardCreate').addEventListener('click', async () => {
|
||||
captureWizardPermissions();
|
||||
try {
|
||||
await api('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: createWizardState.username,
|
||||
password: createWizardState.password,
|
||||
role: createWizardState.role,
|
||||
permissions: createWizardState.permissions
|
||||
})
|
||||
});
|
||||
|
||||
await loadUsers();
|
||||
setCreateWizardVisible(false);
|
||||
resetCreateWizard();
|
||||
setStatus('User created successfully.', false);
|
||||
} catch (error) {
|
||||
setStatus(`Failed to create user: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
document.getElementById('savePermissions').addEventListener('click', async () => {
|
||||
if (!selectedUserId) {
|
||||
setStatus('No user selected.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const permissions = selectedPermissionsFromContainer(document.getElementById('permissionEditorGroups'));
|
||||
await api(`/api/users/${selectedUserId}/permissions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ permissions })
|
||||
});
|
||||
await loadUsers();
|
||||
setStatus('Permissions updated successfully.', false);
|
||||
} catch (error) {
|
||||
setStatus(`Failed to update permissions: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await loadMe();
|
||||
await loadUsers();
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - Whitelist</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
<a class="side-link" href="/dashboard/extensions">Extensions</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>Whitelist</h1>
|
||||
<p>View, add, and remove whitelisted players.</p>
|
||||
|
||||
<section class="card">
|
||||
<h2>Whitelist Status</h2>
|
||||
<div class="toolbar">
|
||||
<strong id="whitelistStatus" style="align-self:center;min-width:180px;">Status: Unknown</strong>
|
||||
<button id="enableWhitelistBtn">Enable Whitelist</button>
|
||||
<button id="disableWhitelistBtn" class="secondary">Disable Whitelist</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Add Player</h2>
|
||||
<div class="toolbar">
|
||||
<input id="usernameInput" placeholder="Minecraft username">
|
||||
<button id="addBtn">Add to Whitelist</button>
|
||||
<button id="refreshBtn" class="secondary">Refresh</button>
|
||||
</div>
|
||||
<div id="statusMessage" class="warning" style="display:none;margin-top:10px;"></div>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Whitelisted Players (<span id="count">0</span>)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>UUID</th>
|
||||
<th>Online</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="whitelistBody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
// Ignore unavailable sessionStorage.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const nextExpanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', nextExpanded);
|
||||
toggle.classList.toggle('expanded', nextExpanded);
|
||||
savedState[category] = nextExpanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.error || 'Request failed');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
function showStatus(message, isError) {
|
||||
const box = document.getElementById('statusMessage');
|
||||
if (!message) {
|
||||
box.style.display = 'none';
|
||||
box.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
box.style.display = 'block';
|
||||
box.textContent = message;
|
||||
box.style.borderColor = isError ? 'rgba(231, 76, 60, 0.45)' : 'rgba(46, 204, 113, 0.45)';
|
||||
box.style.background = isError ? 'rgba(231, 76, 60, 0.16)' : 'rgba(46, 204, 113, 0.14)';
|
||||
box.style.color = isError ? '#ffc7c1' : '#b9f8cf';
|
||||
}
|
||||
|
||||
function renderWhitelist(entries) {
|
||||
const tbody = document.getElementById('whitelistBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td colspan="4">No whitelisted players found</td>';
|
||||
tbody.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${entry.username || '-'}</td>
|
||||
<td>${entry.uuid || '-'}</td>
|
||||
<td>${entry.online ? 'Yes' : 'No'}</td>
|
||||
<td><button class="danger" data-remove="${entry.username || ''}">Remove</button></td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
|
||||
tbody.querySelectorAll('button[data-remove]').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const username = button.dataset.remove || '';
|
||||
if (!username) {
|
||||
return;
|
||||
}
|
||||
await removeFromWhitelist(username);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function applyWhitelistStatus(enabled) {
|
||||
const label = document.getElementById('whitelistStatus');
|
||||
const active = enabled === true;
|
||||
label.textContent = `Status: ${active ? 'Enabled' : 'Disabled'}`;
|
||||
label.style.color = active ? '#8af0b4' : '#ff9ca1';
|
||||
}
|
||||
|
||||
async function loadWhitelistStatus() {
|
||||
const data = await api('/api/extensions/whitelist/status');
|
||||
applyWhitelistStatus(data.enabled === true);
|
||||
}
|
||||
|
||||
async function setWhitelistEnabled(enabled) {
|
||||
const data = await api('/api/extensions/whitelist/toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: enabled === true })
|
||||
});
|
||||
applyWhitelistStatus(data.enabled === true);
|
||||
showStatus(`Whitelist ${data.enabled ? 'enabled' : 'disabled'}.`, false);
|
||||
}
|
||||
|
||||
async function loadWhitelist() {
|
||||
const data = await api('/api/extensions/whitelist');
|
||||
const entries = Array.isArray(data.entries) ? data.entries : [];
|
||||
document.getElementById('count').textContent = String(data.count || entries.length);
|
||||
renderWhitelist(entries);
|
||||
}
|
||||
|
||||
async function addToWhitelist() {
|
||||
const input = document.getElementById('usernameInput');
|
||||
const username = input.value.trim();
|
||||
if (!username) {
|
||||
showStatus('Please enter a username.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
await api('/api/extensions/whitelist/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
|
||||
input.value = '';
|
||||
showStatus(`Added ${username} to whitelist.`, false);
|
||||
await loadWhitelist();
|
||||
}
|
||||
|
||||
async function removeFromWhitelist(username) {
|
||||
await api('/api/extensions/whitelist/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
|
||||
showStatus(`Removed ${username} from whitelist.`, false);
|
||||
await loadWhitelist();
|
||||
}
|
||||
|
||||
document.getElementById('addBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await addToWhitelist();
|
||||
} catch (error) {
|
||||
showStatus(error.message || 'Could not add player.', true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await loadWhitelistStatus();
|
||||
await loadWhitelist();
|
||||
showStatus('', false);
|
||||
} catch (error) {
|
||||
showStatus(error.message || 'Could not refresh whitelist.', true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('enableWhitelistBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await setWhitelistEnabled(true);
|
||||
} catch (error) {
|
||||
showStatus(error.message || 'Could not enable whitelist.', true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('disableWhitelistBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await setWhitelistEnabled(false);
|
||||
} catch (error) {
|
||||
showStatus(error.message || 'Could not disable whitelist.', true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('usernameInput').addEventListener('keydown', async event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await addToWhitelist();
|
||||
} catch (error) {
|
||||
showStatus(error.message || 'Could not add player.', true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await loadMe();
|
||||
await loadWhitelistStatus();
|
||||
await loadWhitelist();
|
||||
} catch (error) {
|
||||
if (error && error.status === 401) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
showStatus((error && error.message) ? error.message : 'Could not load whitelist page.', true);
|
||||
console.error('Whitelist page load failed:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MinePanel Dashboard - World Backups</title>
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">MinePanel</div>
|
||||
<div id="me" class="sidebar-meta"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="side-link" href="/dashboard/overview">Overview</a>
|
||||
<button class="side-category-toggle" data-category="server" type="button">Server</button>
|
||||
<div class="side-category-items" data-category-items="server">
|
||||
<a class="side-link" href="/console">Console</a>
|
||||
<a class="side-link" href="/dashboard/resources">Resources</a>
|
||||
<a class="side-link active" href="/dashboard/world-backups">Backups</a>
|
||||
<a class="side-link" href="/dashboard/players">Players</a>
|
||||
<a class="side-link" href="/dashboard/bans">Bans</a>
|
||||
<a class="side-link" href="/dashboard/plugins">Plugins</a>
|
||||
<a class="side-link" href="/dashboard/reports">Reports</a>
|
||||
<a class="side-link" href="/dashboard/tickets">Tickets</a>
|
||||
</div>
|
||||
<button class="side-category-toggle" data-category="panel" type="button">Panel</button>
|
||||
<div class="side-category-items" data-category-items="panel">
|
||||
<a class="side-link" href="/dashboard/users">Users</a>
|
||||
<a class="side-link" href="/dashboard/discord-webhook">Discord Webhook</a>
|
||||
<a class="side-link" href="/dashboard/themes">Themes</a>
|
||||
</div>
|
||||
</nav>
|
||||
<button id="logout" class="secondary sidebar-logout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<h1>World Backups</h1>
|
||||
<p>Create separate backups for Overworld, Nether and End. Backups are saved to <code>plugins/MinePanel/backups</code>.</p>
|
||||
|
||||
<section class="card">
|
||||
<h2>Create Backup</h2>
|
||||
<div class="toolbar">
|
||||
<select id="backupWorld" style="width:auto;min-width:180px;">
|
||||
<option value="overworld">Overworld</option>
|
||||
<option value="nether">Nether</option>
|
||||
<option value="end">End</option>
|
||||
</select>
|
||||
<input id="backupName" placeholder="Backup name (example: before-update)">
|
||||
<button id="createBackup">Create Backup</button>
|
||||
</div>
|
||||
<p id="backupStatus" class="status-line"></p>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Overworld Backups</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>File</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="overworldBackups"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>Nether Backups</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>File</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="netherBackups"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card spaced-top">
|
||||
<h2>End Backups</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>File</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="endBackups"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initSidebarCategories() {
|
||||
const storageKey = 'minepanel.sidebar.categories';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {};
|
||||
} catch (ignored) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(savedState));
|
||||
} catch (ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.side-category-toggle').forEach(toggle => {
|
||||
const category = toggle.dataset.category;
|
||||
const items = document.querySelector(`.side-category-items[data-category-items="${category}"]`);
|
||||
if (!items || !category) return;
|
||||
|
||||
const expanded = savedState[category] === true;
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = !items.classList.contains('expanded');
|
||||
items.classList.toggle('expanded', expanded);
|
||||
toggle.classList.toggle('expanded', expanded);
|
||||
savedState[category] = expanded;
|
||||
persistState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
const detail = payload && payload.details ? ` (${payload.details})` : '';
|
||||
throw new Error(`${payload.error || 'request_failed'}${detail}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const data = await api('/api/me');
|
||||
document.getElementById('me').textContent = `Logged in as ${data.user.username} (${data.user.role})`;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value || Number(value) <= 0) {
|
||||
return '-';
|
||||
}
|
||||
return new Date(Number(value)).toLocaleString();
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
const value = Number(bytes) || 0;
|
||||
if (value < 1024) return `${value} B`;
|
||||
const kb = value / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||
const mb = kb / 1024;
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
const gb = mb / 1024;
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function renderBackupList(world, backups) {
|
||||
const body = document.getElementById(`${world}Backups`);
|
||||
body.innerHTML = '';
|
||||
|
||||
if (!Array.isArray(backups) || backups.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 5;
|
||||
cell.textContent = 'No backups yet';
|
||||
row.appendChild(cell);
|
||||
body.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const backup of backups) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${backup.name || '-'}</td>
|
||||
<td>${backup.fileName || '-'}</td>
|
||||
<td>${formatDate(backup.createdAt)}</td>
|
||||
<td>${formatSize(backup.sizeBytes)}</td>
|
||||
<td></td>
|
||||
`;
|
||||
|
||||
const actionsCell = row.lastElementChild;
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.className = 'secondary';
|
||||
deleteButton.style.width = 'auto';
|
||||
deleteButton.textContent = 'Delete';
|
||||
deleteButton.addEventListener('click', async () => {
|
||||
await deleteBackup(world, backup.fileName);
|
||||
});
|
||||
actionsCell.appendChild(deleteButton);
|
||||
body.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackup(world, fileName) {
|
||||
const status = document.getElementById('backupStatus');
|
||||
if (!fileName) {
|
||||
status.textContent = 'Delete failed: invalid file name.';
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`Delete backup ${fileName}?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = `Deleting backup ${fileName}...`;
|
||||
try {
|
||||
await api('/api/extensions/world-backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ world, fileName })
|
||||
});
|
||||
status.textContent = `Deleted backup ${fileName}.`;
|
||||
await loadBackups();
|
||||
} catch (error) {
|
||||
status.textContent = `Delete failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackups() {
|
||||
const data = await api('/api/extensions/world-backups');
|
||||
renderBackupList('overworld', data.overworld || []);
|
||||
renderBackupList('nether', data.nether || []);
|
||||
renderBackupList('end', data.end || []);
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
const world = document.getElementById('backupWorld').value;
|
||||
const name = document.getElementById('backupName').value.trim();
|
||||
const status = document.getElementById('backupStatus');
|
||||
|
||||
if (!name) {
|
||||
status.textContent = 'Please enter a backup name.';
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = 'Creating backup...';
|
||||
try {
|
||||
await api('/api/extensions/world-backups/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ world, name })
|
||||
});
|
||||
status.textContent = 'Backup created successfully.';
|
||||
document.getElementById('backupName').value = '';
|
||||
await loadBackups();
|
||||
} catch (error) {
|
||||
status.textContent = `Backup failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('createBackup').addEventListener('click', createBackup);
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
initSidebarCategories();
|
||||
await loadMe();
|
||||
await loadBackups();
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user