commit b918dadb0c1917c4daa61ff96a5c18de5c0e85e3 Author: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Wed Apr 1 01:36:01 2026 +0200 new file: .gitignore new file: README.md new file: database/schema.sql new file: paper-plugin/pom.xml new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/PaperLoggerPlugin.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/commands/MCLoggerCommand.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/database/DatabaseManager.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/BlockListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/EntityListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/InventoryListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/LuckPermsListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerChatCommandListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerDeathListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerMiscListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerSessionListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/WorldListener.java new file: paper-plugin/src/main/resources/config.yml new file: paper-plugin/src/main/resources/plugin.yml new file: paper-plugin/target/classes/config.yml new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/PaperLoggerPlugin.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/commands/MCLoggerCommand$RsConsumer.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/commands/MCLoggerCommand.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/database/DatabaseManager$ThrowingRunnable.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/database/DatabaseManager.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/BlockListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/EntityListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/InventoryListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/LuckPermsListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerChatCommandListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerDeathListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerMiscListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerSessionListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/WorldListener.class new file: paper-plugin/target/classes/plugin.yml new file: paper-plugin/target/maven-archiver/pom.properties new file: paper-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file: paper-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file: paper-plugin/target/mclogger-paper-1.0.0.jar new file: paper-plugin/target/original-mclogger-paper-1.0.0.jar new file: velocity-plugin/pom.xml new file: velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/VelocityLoggerPlugin.java new file: velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager.java new file: velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/listeners/VelocityEventListener.java new file: velocity-plugin/src/main/resources/velocity-config.yml new file: velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/VelocityLoggerPlugin.class new file: velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager$ThrowingRunnable.class new file: velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager.class new file: velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/listeners/VelocityEventListener.class new file: velocity-plugin/target/classes/velocity-config.yml new file: velocity-plugin/target/classes/velocity-plugin.json new file: velocity-plugin/target/maven-archiver/pom.properties new file: velocity-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file: velocity-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file: velocity-plugin/target/mclogger-velocity-1.0.0.jar new file: velocity-plugin/target/original-mclogger-velocity-1.0.0.jar new file: web/Dockerfile new file: web/app.py new file: web/blueprints/__init__.py new file: web/blueprints/auth.py new file: web/blueprints/group_admin.py new file: web/blueprints/panel.py new file: web/blueprints/site_admin.py new file: web/config.py new file: web/crypto.py new file: web/docker-compose.yml new file: web/panel_db.py new file: web/requirements.txt new file: web/static/css/style.css new file: web/static/js/main.js new file: web/templates/_pagination.html new file: web/templates/admin/base.html new file: web/templates/admin/dashboard.html new file: web/templates/admin/group_edit.html new file: web/templates/admin/group_members.html new file: web/templates/admin/groups.html new file: web/templates/admin/user_edit.html new file: web/templates/admin/users.html new file: web/templates/auth/admin_login.html new file: web/templates/auth/login.html new file: web/templates/base.html new file: web/templates/blocks.html new file: web/templates/chat.html new file: web/templates/commands.html new file: web/templates/dashboard.html new file: web/templates/deaths.html new file: web/templates/group_admin/base.html new file: web/templates/group_admin/dashboard.html new file: web/templates/group_admin/database.html new file: web/templates/group_admin/member_edit.html new file: web/templates/group_admin/members.html new file: web/templates/login.html new file: web/templates/panel/blocks.html new file: web/templates/panel/chat.html new file: web/templates/panel/commands.html new file: web/templates/panel/dashboard.html new file: web/templates/panel/deaths.html new file: web/templates/panel/no_db.html new file: web/templates/panel/perms.html new file: web/templates/panel/player_detail.html new file: web/templates/panel/players.html new file: web/templates/panel/proxy.html new file: web/templates/panel/server_events.html new file: web/templates/panel/sessions.html new file: web/templates/perms.html new file: web/templates/player_detail.html new file: web/templates/players.html new file: web/templates/proxy.html new file: web/templates/server_events.html new file: web/templates/sessions.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2af84a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Maps - nicht hochladen (zu groß) +maps/ +*.zip + +# Raw logs - nicht hochladen +raw logs/ + +# Statistics - nicht hochladen +stats/ + +# Logs +*.log +logs/ + +# Environment variables +.env +.env.local + +# Docker +.dockerignore + +# OS specific +Thumbs.db +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..34c565c --- /dev/null +++ b/README.md @@ -0,0 +1,280 @@ +# MCLogger + +**Minecraft 1.20–1.21 · Paper + Velocity · MariaDB · Flask-Admin** +Autor: **SimolZimol** + +Umfassendes Logging-System für Minecraft-Netzwerke. Alle relevanten Events werden asynchron in einer MariaDB-Datenbank gespeichert und über ein modernes Flask-Webinterface auswertbar. + +--- + +## Projektstruktur + +``` +Log/ +├── database/ +│ └── schema.sql ← MariaDB-Schema (einmalig ausführen) +│ +├── paper-plugin/ ← Maven-Projekt für Paper 1.20-1.21 +│ ├── pom.xml +│ └── src/main/ +│ ├── java/de/simolzimol/mclogger/paper/ +│ │ ├── PaperLoggerPlugin.java +│ │ ├── database/DatabaseManager.java +│ │ └── listeners/ +│ │ ├── PlayerSessionListener.java +│ │ ├── PlayerChatCommandListener.java +│ │ ├── PlayerDeathListener.java +│ │ ├── PlayerMiscListener.java +│ │ ├── BlockListener.java +│ │ ├── EntityListener.java +│ │ ├── InventoryListener.java +│ │ └── WorldListener.java +│ └── resources/ +│ ├── plugin.yml +│ └── config.yml +│ +├── velocity-plugin/ ← Maven-Projekt für Velocity 3.x +│ ├── pom.xml +│ └── src/main/ +│ ├── java/de/simolzimol/mclogger/velocity/ +│ │ ├── VelocityLoggerPlugin.java +│ │ ├── database/VelocityDatabaseManager.java +│ │ └── listeners/VelocityEventListener.java +│ └── resources/ +│ └── velocity-config.yml +│ +└── web/ ← Python Flask Admin-Interface + ├── app.py + ├── config.py + ├── requirements.txt + ├── templates/ + │ ├── base.html + │ ├── login.html + │ ├── dashboard.html + │ ├── players.html + │ ├── player_detail.html + │ ├── chat.html + │ ├── commands.html + │ ├── blocks.html + │ ├── deaths.html + │ ├── proxy.html + │ ├── sessions.html + │ ├── server_events.html + │ └── _pagination.html + └── static/ + ├── css/style.css + └── js/main.js +``` + +--- + +## Was wird geloggt? + +### Paper-Server +| Kategorie | Events | +|-----------|--------| +| **Sessions** | Login, Logout, Kick (+ IP, Client-Version, Sprache) | +| **Chat** | Alle Chat-Nachrichten mit Welt und Server | +| **Commands** | Alle Spieler-Befehle mit Position | +| **Tode** | Ursache, Mörder, verlorene Items, XP-Level | +| **Respawn** | Bett / Ankerpunkt / Normalrespawn | +| **Teleport** | Alle Ursachen (Portal, Command, Plugin usw.) | +| **Gamemode** | Wechsel mit Ursache | +| **Level/XP** | Level-Änderungen | +| **Blöcke** | Break, Place, Ignite, Burn, Explode + Werkzeug, SilkTouch | +| **Schilder** | Alle Sign-Änderungen mit Inhalt | +| **Entities** | Spawn, Tod, Schaden (durch Spieler), Zähmen, Züchten, Explosion | +| **PvP** | Schaden zwischen Spielern | +| **Inventar** | Item aufheben/fallenlassen, Crafting, Verzauberung, Amboss, Handel | +| **Welt** | Wetter, Donner, Portal, Baum-/Pilzwachstum, Welt-Load/Unload | +| **Server** | Start, Stop, Player-Join, Player-Quit, Kick | +| **Diverses** | Bett betreten/verlassen, Hand-Item-Wechsel, Angeln, Schlafen, Entity-Interaktion, Welt-Wechsel | + +### Velocity-Proxy +| Kategorie | Events | +|-----------|--------| +| **Verbindungen** | Login (+ IP, Protocol-Version, Client-Brand, Ping) | +| **Disconnect** | Grund + Sitzungsdauer | +| **Server-Wechsel** | Von → Nach-Server | +| **Chat** | Proxy-Level-Chat | +| **Commands** | Proxy-Level-Commands | +| **Proxy** | Start / Stop | + +--- + +## Voraussetzungen + +- **Java 17+** +- **Maven 3.8+** +- **MariaDB 10.6+** / MySQL 8+ +- **Paper 1.20–1.21** +- **Velocity 3.x** +- **Python 3.10+** + +--- + +## 1. Datenbank einrichten + +```sql +-- Als root in MariaDB: +SOURCE /pfad/zu/database/schema.sql; + +-- Benutzer anlegen: +CREATE USER 'mclogger'@'%' IDENTIFIED BY 'sicheres_passwort'; +GRANT ALL PRIVILEGES ON mclogger.* TO 'mclogger'@'%'; +FLUSH PRIVILEGES; +``` + +--- + +## 2. Paper-Plugin bauen & installieren + +```bash +cd paper-plugin +mvn clean package -q +cp target/mclogger-paper-1.0.0.jar /dein/paper-server/plugins/ +``` + +Konfiguration bearbeiten (`plugins/MCLogger/config.yml`): + +```yaml +server: + name: "survival-01" # eindeutiger Name + +database: + host: "localhost" + port: 3306 + database: "mclogger" + username: "mclogger" + password: "sicheres_passwort" +``` + +Server neu starten. + +--- + +## 3. Velocity-Plugin bauen & installieren + +```bash +cd velocity-plugin +mvn clean package -q +cp target/mclogger-velocity-1.0.0.jar /dein/velocity-proxy/plugins/ +``` + +Konfiguration bearbeiten (`plugins/mclogger-velocity/velocity-config.yml`): + +```yaml +proxy: + name: "proxy-01" + +database: + host: "localhost" + port: 3306 + database: "mclogger" + username: "mclogger" + password: "sicheres_passwort" +``` + +Proxy neu starten. + +--- + +## 4. Flask-Webinterface starten + +```bash +cd web + +# Python-Umgebung (empfohlen) +python -m venv venv +venv\Scripts\activate # Windows +source venv/bin/activate # Linux/Mac + +pip install -r requirements.txt + +# Umgebungsvariablen setzen (oder config.py direkt bearbeiten) +set MCLOGGER_DB_HOST=localhost +set MCLOGGER_DB_PASSWORD=sicheres_passwort +set MCLOGGER_ADMIN_PASSWORD=admin_passwort +set MCLOGGER_SECRET_KEY=zufaelliger_32_zeichen_schluessel + +python app.py +``` + +Webinterface öffnen: **http://localhost:5000** + +### Für Produktion (Linux mit Gunicorn) + +```bash +pip install gunicorn +gunicorn -w 4 -b 0.0.0.0:5000 app:app +``` + +Oder als systemd-Service: + +```ini +[Unit] +Description=MCLogger Web + +[Service] +WorkingDirectory=/opt/mclogger/web +Environment=MCLOGGER_DB_HOST=localhost +Environment=MCLOGGER_DB_PASSWORD=sicheres_passwort +Environment=MCLOGGER_ADMIN_PASSWORD=admin_passwort +Environment=MCLOGGER_SECRET_KEY=... +ExecStart=/opt/mclogger/web/venv/bin/gunicorn -w 2 -b 127.0.0.1:5000 app:app +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +--- + +## Webinterface-Features + +| Seite | Beschreibung | +|-------|-------------| +| **Dashboard** | Live-Statistiken, Online-Spieler, Aktivitäts-Charts, Top-Playtime | +| **Spieler** | Suchbare Spielerliste mit Profil-Details | +| **Spielerprofil** | Sessions, Chat, Commands, Tode, Teleports, Stats, Proxy-Events in Tabs | +| **Sessions** | Alle Login-/Logout-Sitzungen mit Filterfunktion | +| **Chat** | Volltext-Suche in allen Chat-Nachrichten | +| **Commands** | Alle ausgeführten Befehle | +| **Block-Events** | Break/Place/Ignite usw. mit Positions-Info | +| **Tode** | Tode mit Ursache, Mörder und Todes-Meldung | +| **Proxy-Events** | Login, Disconnect, Server-Wechsel usw. | +| **Server-Events** | Start/Stop und reine Server-Ereignisse | + +--- + +## Konfigurationsvariablen (Umgebungsvariablen) + +| Variable | Standard | Beschreibung | +|----------|----------|-------------| +| `MCLOGGER_DB_HOST` | `localhost` | MariaDB-Host | +| `MCLOGGER_DB_PORT` | `3306` | MariaDB-Port | +| `MCLOGGER_DB_USER` | `mclogger` | DB-Benutzer | +| `MCLOGGER_DB_PASSWORD` | *(leer)* | DB-Passwort | +| `MCLOGGER_DB_NAME` | `mclogger` | Datenbankname | +| `MCLOGGER_HOST` | `0.0.0.0` | Webserver-Bind | +| `MCLOGGER_PORT` | `5000` | Webserver-Port | +| `MCLOGGER_ADMIN_USER` | `admin` | Admin-Login | +| `MCLOGGER_ADMIN_PASSWORD` | *(leer)* | Admin-Passwort | +| `MCLOGGER_SECRET_KEY` | *(unsicher)* | Flask Session-Key | + +--- + +## Performance-Hinweise + +- Alle Datenbank-Writes erfolgen **asynchron** → kein Tick-Lag +- HikariCP Connection-Pool voreingestellt auf 10 (Paper) / 5 (Velocity) +- Entity-Spawn-Logging ist standardmäßig **deaktiviert** (sehr viele Events) +- Block-Events können bei Farms sehr viele Einträge erzeugen → ggf. `blocks-break-only: true` +- Ein regelmäßiges DB-Backup empfiehlt sich (Events wachsen schnell) +- MariaDB: Index-Tuning für `timestamp`-Spalten ist bereits im Schema enthalten + +--- + +## Lizenz + +MIT – Frei verwendbar, SimolZimol als Autor nennen. diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..bb79762 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,360 @@ +-- ============================================================ +-- MCLogger - MariaDB Schema +-- Author: SimolZimol +-- Minecraft 1.20-1.21 | Paper + Velocity +-- ============================================================ + +CREATE DATABASE IF NOT EXISTS mclogger CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE mclogger; + +-- ------------------------------------------------------- +-- Tabelle: servers +-- Registriert alle bekannten Server-Instanzen +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS servers ( + id INT AUTO_INCREMENT PRIMARY KEY, + server_name VARCHAR(100) NOT NULL, + server_type ENUM('paper', 'velocity') NOT NULL DEFAULT 'paper', + ip_address VARCHAR(45), + mc_version VARCHAR(20), + first_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + last_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uq_server_name (server_name) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: players +-- Bekannte Spieler mit Basisdaten +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS players ( + uuid VARCHAR(36) PRIMARY KEY, + username VARCHAR(16) NOT NULL, + display_name VARCHAR(64), + ip_address VARCHAR(45), + locale VARCHAR(20), + first_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + last_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + total_playtime_sec BIGINT DEFAULT 0, + is_op TINYINT(1) DEFAULT 0, + INDEX idx_username (username) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: player_sessions +-- Login-/Logout-Sitzungen +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS player_sessions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + player_uuid VARCHAR(36) NOT NULL, + player_name VARCHAR(16) NOT NULL, + server_name VARCHAR(100), + login_time TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + logout_time TIMESTAMP(3), + duration_sec INT, + ip_address VARCHAR(45), + client_version VARCHAR(20), + INDEX idx_ps_uuid (player_uuid), + INDEX idx_ps_server (server_name), + INDEX idx_ps_login (login_time) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: player_chat +-- Alle Chat-Nachrichten +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS player_chat ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + player_uuid VARCHAR(36), + player_name VARCHAR(16), + server_name VARCHAR(100), + world VARCHAR(100), + message TEXT NOT NULL, + channel VARCHAR(50) DEFAULT 'global', + INDEX idx_chat_uuid (player_uuid), + INDEX idx_chat_timestamp (timestamp), + INDEX idx_chat_server (server_name), + FULLTEXT INDEX ft_message (message) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: player_commands +-- Alle ausgeführten Befehle +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS player_commands ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + player_uuid VARCHAR(36), + player_name VARCHAR(16), + server_name VARCHAR(100), + world VARCHAR(100), + command TEXT NOT NULL, + x DOUBLE, + y DOUBLE, + z DOUBLE, + INDEX idx_cmd_uuid (player_uuid), + INDEX idx_cmd_timestamp (timestamp), + INDEX idx_cmd_server (server_name) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: block_events +-- Block-Break / Block-Place und weitere Block-Aktionen +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS block_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + event_type ENUM('break','place','ignite','burn','explode','fade','grow','dispense') NOT NULL, + player_uuid VARCHAR(36), + player_name VARCHAR(16), + server_name VARCHAR(100), + world VARCHAR(100) NOT NULL, + x INT NOT NULL, + y INT NOT NULL, + z INT NOT NULL, + block_type VARCHAR(100) NOT NULL, + block_data VARCHAR(255), + tool VARCHAR(100), + is_silk TINYINT(1) DEFAULT 0, + INDEX idx_be_player (player_uuid), + INDEX idx_be_timestamp (timestamp), + INDEX idx_be_world (world), + INDEX idx_be_type (event_type), + INDEX idx_be_server (server_name) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: player_deaths +-- Tode mit vollem Kontext +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS player_deaths ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + player_uuid VARCHAR(36) NOT NULL, + player_name VARCHAR(16) NOT NULL, + server_name VARCHAR(100), + world VARCHAR(100), + x DOUBLE, + y DOUBLE, + z DOUBLE, + death_message TEXT, + cause VARCHAR(100), + killer_uuid VARCHAR(36), + killer_name VARCHAR(100), + killer_type VARCHAR(100), + exp_level INT, + items_lost JSON, + INDEX idx_deaths_uuid (player_uuid), + INDEX idx_deaths_timestamp (timestamp) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: entity_events +-- Entity-Spawns, -Tode, Schaden usw. +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS entity_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + event_type ENUM('spawn','death','damage','tame','breed','transform','explode') NOT NULL, + server_name VARCHAR(100), + world VARCHAR(100), + x DOUBLE, + y DOUBLE, + z DOUBLE, + entity_type VARCHAR(100) NOT NULL, + entity_uuid VARCHAR(36), + entity_name VARCHAR(100), + player_uuid VARCHAR(36), + player_name VARCHAR(16), + cause VARCHAR(100), + damage DOUBLE, + details JSON, + INDEX idx_ee_timestamp (timestamp), + INDEX idx_ee_type (event_type), + INDEX idx_ee_entity (entity_type), + INDEX idx_ee_player (player_uuid), + INDEX idx_ee_server (server_name) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: player_teleports +-- Alle Teleportationen (Commands, Death, etc.) +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS player_teleports ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + player_uuid VARCHAR(36) NOT NULL, + player_name VARCHAR(16) NOT NULL, + server_name VARCHAR(100), + from_world VARCHAR(100), + from_x DOUBLE, + from_y DOUBLE, + from_z DOUBLE, + to_world VARCHAR(100), + to_x DOUBLE, + to_y DOUBLE, + to_z DOUBLE, + cause VARCHAR(100), + INDEX idx_tp_uuid (player_uuid), + INDEX idx_tp_timestamp (timestamp) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: inventory_events +-- Item-Picks, Drops, Inventory-Klicks +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS inventory_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + event_type ENUM('pickup','drop','click','craft','enchant','anvil','trade') NOT NULL, + player_uuid VARCHAR(36) NOT NULL, + player_name VARCHAR(16) NOT NULL, + server_name VARCHAR(100), + world VARCHAR(100), + x DOUBLE, + y DOUBLE, + z DOUBLE, + item_type VARCHAR(100), + item_amount INT, + item_meta JSON, + slot INT, + inventory_type VARCHAR(100), + INDEX idx_inv_uuid (player_uuid), + INDEX idx_inv_timestamp (timestamp), + INDEX idx_inv_type (event_type) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: player_stats +-- Gamemode-Wechsel, Level-Änderungen, OP-Status usw. +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS player_stats ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + event_type VARCHAR(100) NOT NULL, + player_uuid VARCHAR(36) NOT NULL, + player_name VARCHAR(16) NOT NULL, + server_name VARCHAR(100), + old_value VARCHAR(255), + new_value VARCHAR(255), + details JSON, + INDEX idx_pst_uuid (player_uuid), + INDEX idx_pst_timestamp (timestamp), + INDEX idx_pst_type (event_type) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: world_events +-- Wetter, Zeit, Explosionen, Portale usw. +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS world_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + event_type VARCHAR(100) NOT NULL, + server_name VARCHAR(100), + world VARCHAR(100), + x DOUBLE, + y DOUBLE, + z DOUBLE, + details JSON, + INDEX idx_we_timestamp (timestamp), + INDEX idx_we_world (world), + INDEX idx_we_type (event_type) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: server_events +-- Server-Start, -Stop, Plugin-Events, Konsolen-Commands +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS server_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + event_type VARCHAR(100) NOT NULL, + server_name VARCHAR(100), + message TEXT, + details JSON, + INDEX idx_se_timestamp (timestamp), + INDEX idx_se_type (event_type), + INDEX idx_se_server (server_name) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: proxy_events +-- Velocity: Login, Disconnect, Server-Wechsel +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS proxy_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + event_type ENUM('login','disconnect','server_switch','command','chat','kick','proxy_start','proxy_stop') NOT NULL, + player_uuid VARCHAR(36), + player_name VARCHAR(16), + proxy_name VARCHAR(100), + from_server VARCHAR(100), + to_server VARCHAR(100), + ip_address VARCHAR(45), + details JSON, + INDEX idx_pe_uuid (player_uuid), + INDEX idx_pe_timestamp (timestamp), + INDEX idx_pe_type (event_type), + INDEX idx_pe_proxy (proxy_name) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: sign_edits +-- SignChangeEvents +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS sign_edits ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + player_uuid VARCHAR(36), + player_name VARCHAR(16), + server_name VARCHAR(100), + world VARCHAR(100), + x INT, + y INT, + z INT, + line1 VARCHAR(255), + line2 VARCHAR(255), + line3 VARCHAR(255), + line4 VARCHAR(255), + INDEX idx_sign_uuid (player_uuid), + INDEX idx_sign_timestamp (timestamp) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- Tabelle: plugin_events +-- LuckPerms Berechtigungsänderungen und andere Plugin-Events +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS plugin_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + event_type VARCHAR(100) NOT NULL, + plugin_name VARCHAR(100), + server_name VARCHAR(100), + player_uuid VARCHAR(36), + player_name VARCHAR(16), + actor_uuid VARCHAR(36), + actor_name VARCHAR(64), + target_type VARCHAR(50), + target_id VARCHAR(100), + action VARCHAR(255), + details JSON, + INDEX idx_plev_uuid (player_uuid), + INDEX idx_plev_timestamp (timestamp), + INDEX idx_plev_type (event_type), + INDEX idx_plev_plugin (plugin_name), + INDEX idx_plev_actor (actor_uuid) +) ENGINE=InnoDB; + +-- ------------------------------------------------------- +-- View: v_recent_activity - Letzte 24h kompakt +-- ------------------------------------------------------- +CREATE OR REPLACE VIEW v_recent_activity AS +SELECT 'chat' AS source, timestamp, player_name, server_name, message AS detail FROM player_chat WHERE timestamp >= NOW() - INTERVAL 24 HOUR +UNION ALL +SELECT 'command' AS source, timestamp, player_name, server_name, command AS detail FROM player_commands WHERE timestamp >= NOW() - INTERVAL 24 HOUR +UNION ALL +SELECT 'block' AS source, timestamp, player_name, server_name, CONCAT(event_type,' ',block_type,' at ',world,' ',x,',',y,',',z) AS detail FROM block_events WHERE timestamp >= NOW() - INTERVAL 24 HOUR +UNION ALL +SELECT 'death' AS source, timestamp, player_name, server_name, death_message AS detail FROM player_deaths WHERE timestamp >= NOW() - INTERVAL 24 HOUR +ORDER BY timestamp DESC; diff --git a/paper-plugin/pom.xml b/paper-plugin/pom.xml new file mode 100644 index 0000000..0539da8 --- /dev/null +++ b/paper-plugin/pom.xml @@ -0,0 +1,113 @@ + + + 4.0.0 + + de.simolzimol + mclogger-paper + 1.0.0 + jar + + MCLogger-Paper + Comprehensive Minecraft event logger + + + 17 + 17 + 17 + UTF-8 + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + lucko + https://repo.lucko.me/ + + + + + + + io.papermc.paper + paper-api + 1.21-R0.1-SNAPSHOT + provided + + + + + com.zaxxer + HikariCP + 5.1.0 + + + + + org.mariadb.jdbc + mariadb-java-client + 3.4.1 + + + + + com.google.code.gson + gson + 2.11.0 + provided + + + + + net.luckperms + api + 5.4 + provided + + + + + ${project.artifactId}-${project.version} + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.2 + + + package + shade + + false + + + com.zaxxer.hikari + de.simolzimol.mclogger.lib.hikari + + + org.mariadb + de.simolzimol.mclogger.lib.mariadb + + + + + *:* + + META-INF/LICENSE* + META-INF/NOTICE* + META-INF/DEPENDENCIES + + + + + + + + + + diff --git a/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/PaperLoggerPlugin.java b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/PaperLoggerPlugin.java new file mode 100644 index 0000000..a953119 --- /dev/null +++ b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/PaperLoggerPlugin.java @@ -0,0 +1,118 @@ +package de.simolzimol.mclogger.paper; + +import de.simolzimol.mclogger.paper.commands.MCLoggerCommand; +import de.simolzimol.mclogger.paper.database.DatabaseManager; +import de.simolzimol.mclogger.paper.listeners.*; +import net.luckperms.api.LuckPerms; +import org.bukkit.command.PluginCommand; +import org.bukkit.plugin.RegisteredServiceProvider; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Level; + +/** + * MCLogger – Paper Plugin entry point + * + * @author SimolZimol + * @version 1.0.0 + */ +public class PaperLoggerPlugin extends JavaPlugin { + + private static PaperLoggerPlugin instance; + private DatabaseManager db; + + @Override + public void onEnable() { + instance = this; + + saveDefaultConfig(); + + // Establish database connection + db = new DatabaseManager(this); + if (!db.connect()) { + getLogger().severe("[MCLogger] Could not connect to database – disabling plugin."); + getServer().getPluginManager().disablePlugin(this); + return; + } + + // Register plugin messaging channel towards the Velocity proxy + getServer().getMessenger().registerOutgoingPluginChannel(this, "mclogger:logged"); + + // Register all listeners + registerListeners(); + + // LuckPerms listener (soft-depend) + registerLuckPermsListener(); + + // Register /mclogger command + MCLoggerCommand cmdHandler = new MCLoggerCommand(this); + PluginCommand cmd = getCommand("mclogger"); + if (cmd != null) { + cmd.setExecutor(cmdHandler); + cmd.setTabCompleter(cmdHandler); + } + + // Server-Start Event loggen + Map details = new HashMap<>(); + details.put("version", getServer().getVersion()); + details.put("bukkit_version", getServer().getBukkitVersion()); + details.put("online_mode", getServer().getOnlineMode()); + details.put("max_players", getServer().getMaxPlayers()); + db.insertServerEvent("server_start", "Server started", details); + + getLogger().info("[MCLogger] started successfully! Server: " + db.getServerName()); + } + + @Override + public void onDisable() { + if (db != null) { + Map details = new HashMap<>(); + details.put("online_players", getServer().getOnlinePlayers().size()); + db.insertServerEvent("server_stop", "Server stopping", details); + // Short delay to let async tasks finish + try { Thread.sleep(500); } catch (InterruptedException ignored) {} + db.disconnect(); + } + getLogger().info("[MCLogger] disabled."); + } + + private void registerListeners() { + PluginManager pm = getServer().getPluginManager(); + pm.registerEvents(new PlayerSessionListener(this), this); + pm.registerEvents(new PlayerChatCommandListener(this), this); + pm.registerEvents(new PlayerDeathListener(this), this); + pm.registerEvents(new PlayerMiscListener(this), this); + pm.registerEvents(new BlockListener(this), this); + pm.registerEvents(new EntityListener(this), this); + pm.registerEvents(new InventoryListener(this), this); + pm.registerEvents(new WorldListener(this), this); + getLogger().info("[MCLogger] All listeners registered."); + } + + /** + * Registers the LuckPerms listener only if LuckPerms is actually loaded (softdepend). + */ + private void registerLuckPermsListener() { + if (getServer().getPluginManager().getPlugin("LuckPerms") == null) { + getLogger().info("[MCLogger] LuckPerms not found – permission logging disabled."); + return; + } + try { + RegisteredServiceProvider provider = + getServer().getServicesManager().getRegistration(LuckPerms.class); + if (provider != null) { + new LuckPermsListener(this, provider.getProvider()); + } else { + getLogger().warning("[MCLogger] LuckPerms service not available."); + } + } catch (Exception e) { + getLogger().log(Level.WARNING, "[MCLogger] Error registering LuckPerms listener.", e); + } + } + + public static PaperLoggerPlugin getInstance() { return instance; } + public DatabaseManager getDb() { return db; } +} diff --git a/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/commands/MCLoggerCommand.java b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/commands/MCLoggerCommand.java new file mode 100644 index 0000000..1e58a8b --- /dev/null +++ b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/commands/MCLoggerCommand.java @@ -0,0 +1,484 @@ +package de.simolzimol.mclogger.paper.commands; + +import de.simolzimol.mclogger.paper.PaperLoggerPlugin; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.jetbrains.annotations.NotNull; + +import java.sql.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * In-game command class for /mclogger. + * + * Sub-commands: + * /mclogger help – Help (mclogger.use) + * /mclogger status – DB status (mclogger.admin) + * /mclogger reload – Reload config (mclogger.admin) + * /mclogger chat [page] – Chat log (mclogger.view.chat) + * /mclogger commands [page] – Command log (mclogger.view.commands) + * /mclogger deaths [page] – Death log (mclogger.view.deaths) + * /mclogger sessions [page] – Session log (mclogger.view.sessions) + * /mclogger blocks [page]– Block log (mclogger.view.blocks) + * /mclogger perms player [page] – Perms by target player (mclogger.view.perms) + * /mclogger perms actor [page] – Perms by actor (mclogger.view.perms) + * /mclogger perms group [page] – Perms by group (mclogger.view.perms) + * /mclogger perms all [page] – All perm events (mclogger.view.perms) + * + * @author SimolZimol + */ +public class MCLoggerCommand implements CommandExecutor, TabCompleter { + + private static final int PAGE_SIZE = 8; + + private final PaperLoggerPlugin plugin; + + public MCLoggerCommand(PaperLoggerPlugin plugin) { + this.plugin = plugin; + } + + // -------------------------------------------------------- + // Tab-Completion + // -------------------------------------------------------- + + @Override + public List onTabComplete(@NotNull CommandSender sender, @NotNull Command cmd, + @NotNull String label, String[] args) { + if (!sender.hasPermission("mclogger.use")) return List.of(); + + if (args.length == 1) { + List subs = new ArrayList<>(List.of("help", "status", "reload", + "chat", "commands", "deaths", "sessions", "blocks", "perms")); + String partial = args[0].toLowerCase(); + subs.removeIf(s -> !s.startsWith(partial)); + return subs; + } + if (args.length == 2 && args[0].equalsIgnoreCase("perms")) { + return List.of("player", "actor", "group", "all").stream() + .filter(s -> s.startsWith(args[1].toLowerCase())) + .collect(Collectors.toList()); + } + if (args.length == 3 && args[0].equalsIgnoreCase("perms") + && (args[1].equalsIgnoreCase("player") || args[1].equalsIgnoreCase("actor"))) { + return plugin.getServer().getOnlinePlayers().stream() + .map(p -> p.getName()) + .filter(n -> n.toLowerCase().startsWith(args[2].toLowerCase())) + .collect(Collectors.toList()); + } + + if (args.length == 2) { + String sub = args[0].toLowerCase(); + return switch (sub) { + case "chat", "commands", "deaths", "sessions", "perms" -> + plugin.getServer().getOnlinePlayers().stream() + .map(p -> p.getName()) + .filter(n -> n.toLowerCase().startsWith(args[1].toLowerCase())) + .toList(); + default -> List.of(); + }; + } + + return List.of(); + } + + // -------------------------------------------------------- + // Command handler + // -------------------------------------------------------- + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, + @NotNull String label, String[] args) { + if (!sender.hasPermission("mclogger.use")) { + send(sender, NamedTextColor.RED, "You don't have permission to use this command."); + return true; + } + + if (args.length == 0) { + handleHelp(sender); + return true; + } + + return switch (args[0].toLowerCase()) { + case "help" -> { handleHelp(sender); yield true; } + case "status" -> { handleStatus(sender); yield true; } + case "reload" -> { handleReload(sender); yield true; } + case "chat" -> { handleChat(sender, args); yield true; } + case "commands" -> { handleCommands(sender, args); yield true; } + case "deaths" -> { handleDeaths(sender, args); yield true; } + case "sessions" -> { handleSessions(sender, args); yield true; } + case "blocks" -> { handleBlocks(sender, args); yield true; } + case "perms" -> { handlePerms(sender, args); yield true; } + default -> { handleHelp(sender); yield true; } + }; + } + + // -------------------------------------------------------- + // Sub-commands + // -------------------------------------------------------- + + private void handleHelp(CommandSender s) { + sendHeader(s, "MCLogger Help"); + sendLine(s, "/mcl help", "This help"); + if (s.hasPermission("mclogger.admin")) { + sendLine(s, "/mcl status", "Database status"); + sendLine(s, "/mcl reload", "Reload configuration"); + } + if (s.hasPermission("mclogger.view.chat")) + sendLine(s, "/mcl chat [page]", "Chat history"); + if (s.hasPermission("mclogger.view.commands")) + sendLine(s, "/mcl commands [page]", "Command history"); + if (s.hasPermission("mclogger.view.deaths")) + sendLine(s, "/mcl deaths [page]", "Death history"); + if (s.hasPermission("mclogger.view.sessions")) + sendLine(s, "/mcl sessions [page]", "Session history"); + if (s.hasPermission("mclogger.view.blocks")) + sendLine(s, "/mcl blocks [page]","Block log at position"); + if (s.hasPermission("mclogger.view.perms")) + sendLine(s, "/mcl perms [page]", "LuckPerms changes"); + } + + private void handleStatus(CommandSender s) { + requirePerm(s, "mclogger.admin", () -> { + boolean ok = plugin.getDb().isConnected(); + send(s, ok ? NamedTextColor.GREEN : NamedTextColor.RED, + "Database connection: " + (ok ? "✔ CONNECTED" : "✘ DISCONNECTED")); + send(s, NamedTextColor.AQUA, "Server: " + plugin.getDb().getServerName() + + " | ID: " + plugin.getDb().getServerId()); + }); + } + + private void handleReload(CommandSender s) { + requirePerm(s, "mclogger.admin", () -> { + plugin.reloadConfig(); + send(s, NamedTextColor.GREEN, "Configuration reloaded."); + }); + } + + // -- Chat ----------------------------------------------- + + private void handleChat(CommandSender s, String[] args) { + requirePerm(s, "mclogger.view.chat", () -> { + if (args.length < 2) { send(s, NamedTextColor.YELLOW, "Usage: /mcl chat [page]"); return; } + String player = args[1]; + int page = parsePage(args, 2); + runQuery(s, + "SELECT timestamp, message FROM player_chat " + + "WHERE player_name = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?", + player, page, + rs -> { + sendHeader(s, "Chat: " + player + " (page " + page + ")"); + while (rs.next()) { + send(s, NamedTextColor.GRAY, "[" + fmtTs(rs.getString("timestamp")) + "] " + + rs.getString("message")); + } + }); + }); + } + + // -- Commands ------------------------------------------- + + private void handleCommands(CommandSender s, String[] args) { + requirePerm(s, "mclogger.view.commands", () -> { + if (args.length < 2) { send(s, NamedTextColor.YELLOW, "Usage: /mcl commands [page]"); return; } + String player = args[1]; + int page = parsePage(args, 2); + runQuery(s, + "SELECT timestamp, command FROM player_commands " + + "WHERE player_name = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?", + player, page, + rs -> { + sendHeader(s, "Commands: " + player + " (page " + page + ")"); + while (rs.next()) { + send(s, NamedTextColor.GRAY, "[" + fmtTs(rs.getString("timestamp")) + "] " + + rs.getString("command")); + } + }); + }); + } + + // -- Deaths --------------------------------------------- + + private void handleDeaths(CommandSender s, String[] args) { + requirePerm(s, "mclogger.view.deaths", () -> { + if (args.length < 2) { send(s, NamedTextColor.YELLOW, "Usage: /mcl deaths [page]"); return; } + String player = args[1]; + int page = parsePage(args, 2); + runQuery(s, + "SELECT timestamp, death_message, world, x, y, z FROM player_deaths " + + "WHERE player_name = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?", + player, page, + rs -> { + sendHeader(s, "Deaths: " + player + " (page " + page + ")"); + while (rs.next()) { + send(s, NamedTextColor.RED, + "[" + fmtTs(rs.getString("timestamp")) + "] " + rs.getString("death_message") + + " @ " + rs.getString("world") + " " + + (int)rs.getDouble("x") + "/" + + (int)rs.getDouble("y") + "/" + + (int)rs.getDouble("z")); + } + }); + }); + } + + // -- Sessions ------------------------------------------- + + private void handleSessions(CommandSender s, String[] args) { + requirePerm(s, "mclogger.view.sessions", () -> { + if (args.length < 2) { send(s, NamedTextColor.YELLOW, "Usage: /mcl sessions [page]"); return; } + String player = args[1]; + int page = parsePage(args, 2); + runQuery(s, + "SELECT login_time, logout_time, duration_sec, ip_address, country, server_name FROM player_sessions " + + "WHERE player_name = ? ORDER BY login_time DESC LIMIT ? OFFSET ?", + player, page, + rs -> { + sendHeader(s, "Sessions: " + player + " (page " + page + ")"); + while (rs.next()) { + long dur = rs.getLong("duration_sec"); + String durStr = dur > 0 ? formatDuration(dur) : "ongoing"; + String country = rs.getString("country"); + String countryStr = (country != null && !country.isEmpty()) ? " [" + country + "]" : ""; + send(s, NamedTextColor.AQUA, + "[" + fmtTs(rs.getString("login_time")) + "] " + + rs.getString("server_name") + " " + durStr + + " IP: " + rs.getString("ip_address") + countryStr); + } + }); + }); + } + + // -- Blocks --------------------------------------------- + + private void handleBlocks(CommandSender s, String[] args) { + requirePerm(s, "mclogger.view.blocks", () -> { + if (args.length < 4) { send(s, NamedTextColor.YELLOW, "Usage: /mcl blocks [page]"); return; } + int bx, by, bz; + try { bx = Integer.parseInt(args[1]); by = Integer.parseInt(args[2]); bz = Integer.parseInt(args[3]); } + catch (NumberFormatException ex) { send(s, NamedTextColor.RED, "Coordinates must be integers."); return; } + int page = parsePage(args, 4); + runQuery(s, + "SELECT timestamp, player_name, event_type, block_type FROM block_events " + + "WHERE x = ? AND y = ? AND z = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?", + bx, by, bz, page, + rs -> { + sendHeader(s, "Blocks @ " + bx + "/" + by + "/" + bz + " (page " + page + ")"); + while (rs.next()) { + send(s, NamedTextColor.GOLD, + "[" + fmtTs(rs.getString("timestamp")) + "] " + + rs.getString("player_name") + " " + + rs.getString("event_type") + " " + + rs.getString("block_type")); + } + }); + }); + } + + // -- Perms ---------------------------------------------- + + private void handlePerms(CommandSender s, String[] args) { + requirePerm(s, "mclogger.view.perms", () -> { + if (args.length < 2) { + send(s, NamedTextColor.YELLOW, + "Usage: /mcl perms [name] [page]"); + return; + } + String type = args[1].toLowerCase(); + switch (type) { + case "all" -> { + int page = parsePage(args, 2); + runQueryParams(s, + "SELECT timestamp, event_type, player_name, actor_name, target_type, action, server_name FROM plugin_events " + + "WHERE plugin_name = 'LuckPerms' ORDER BY timestamp DESC LIMIT ? OFFSET ?", + List.of(), page, + rs -> { + sendHeader(s, "All Permission Events (page " + page + ")"); + while (rs.next()) printPermRow(s, rs); + }); + } + case "player" -> { + if (args.length < 3) { send(s, NamedTextColor.YELLOW, "Usage: /mcl perms player [page]"); return; } + String name = args[2]; int page = parsePage(args, 3); + runQueryParams(s, + "SELECT timestamp, event_type, player_name, actor_name, target_type, action, server_name FROM plugin_events " + + "WHERE plugin_name = 'LuckPerms' AND (player_name = ? OR player_name LIKE ?) ORDER BY timestamp DESC LIMIT ? OFFSET ?", + List.of(name, name + "@%"), page, + rs -> { + sendHeader(s, "Perms for player " + name + " (page " + page + ")"); + while (rs.next()) printPermRow(s, rs); + }); + } + case "actor" -> { + if (args.length < 3) { send(s, NamedTextColor.YELLOW, "Usage: /mcl perms actor [page]"); return; } + String name = args[2]; int page = parsePage(args, 3); + runQueryParams(s, + "SELECT timestamp, event_type, player_name, actor_name, target_type, action, server_name FROM plugin_events " + + "WHERE plugin_name = 'LuckPerms' AND (actor_name = ? OR actor_name LIKE ?) ORDER BY timestamp DESC LIMIT ? OFFSET ?", + List.of(name, name + "@%"), page, + rs -> { + sendHeader(s, "Perms by actor " + name + " (page " + page + ")"); + while (rs.next()) printPermRow(s, rs); + }); + } + case "group" -> { + if (args.length < 3) { send(s, NamedTextColor.YELLOW, "Usage: /mcl perms group [page]"); return; } + String name = args[2]; int page = parsePage(args, 3); + runQueryParams(s, + "SELECT timestamp, event_type, player_name, actor_name, target_type, action, server_name FROM plugin_events " + + "WHERE plugin_name = 'LuckPerms' AND target_type = 'group' AND target_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?", + List.of(name), page, + rs -> { + sendHeader(s, "Perms for group " + name + " (page " + page + ")"); + while (rs.next()) printPermRow(s, rs); + }); + } + default -> send(s, NamedTextColor.YELLOW, + "Usage: /mcl perms [name] [page]"); + } + }); + } + + private void printPermRow(CommandSender s, ResultSet rs) throws SQLException { + String srv = rs.getString("server_name"); + String srvStr = (srv != null && !srv.isEmpty()) ? " [" + srv + "]" : ""; + send(s, NamedTextColor.LIGHT_PURPLE, + "[" + fmtTs(rs.getString("timestamp")) + "]" + srvStr + " " + + rs.getString("actor_name") + " → " + rs.getString("action") + + (rs.getString("player_name") != null ? " (player: " + rs.getString("player_name") + ")" : "")); + } + + // -------------------------------------------------------- + // DB helper infrastructure (sync – acceptable for short admin queries) + // -------------------------------------------------------- + + @FunctionalInterface + interface RsConsumer { void accept(ResultSet rs) throws SQLException; } + + /** Executes a paginated query asynchronously (acceptable for admin commands). */ + private void runQuery(CommandSender s, String sql, String player, int page, RsConsumer consumer) { + int offset = (page - 1) * PAGE_SIZE; + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + try (Connection con = plugin.getDb().getConnection(); + PreparedStatement ps = con.prepareStatement(sql)) { + ps.setString(1, player); + ps.setInt(2, PAGE_SIZE); + ps.setInt(3, offset); + ResultSet rs = ps.executeQuery(); + plugin.getServer().getScheduler().runTask(plugin, () -> { + try { consumer.accept(rs); } + catch (SQLException ex) { send(s, NamedTextColor.RED, "Database error: " + ex.getMessage()); } + }); + } catch (SQLException ex) { + send(s, NamedTextColor.RED, "Database error: " + ex.getMessage()); + } + }); + } + + /** Overload for queries with a variable list of leading params before LIMIT/OFFSET. */ + private void runQueryParams(CommandSender s, String sql, List params, int page, RsConsumer consumer) { + int offset = (page - 1) * PAGE_SIZE; + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + try (Connection con = plugin.getDb().getConnection(); + PreparedStatement ps = con.prepareStatement(sql)) { + int i = 1; + for (String p : params) ps.setString(i++, p); + ps.setInt(i++, PAGE_SIZE); + ps.setInt(i, offset); + ResultSet rs = ps.executeQuery(); + plugin.getServer().getScheduler().runTask(plugin, () -> { + try { consumer.accept(rs); } + catch (SQLException ex) { send(s, NamedTextColor.RED, "Database error: " + ex.getMessage()); } + }); + } catch (SQLException ex) { + send(s, NamedTextColor.RED, "Database error: " + ex.getMessage()); + } + }); + } + + /** Overload for block queries with 3 coordinates. */ + private void runQuery(CommandSender s, String sql, + int bx, int by, int bz, int page, RsConsumer consumer) { + int offset = (page - 1) * PAGE_SIZE; + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + try (Connection con = plugin.getDb().getConnection(); + PreparedStatement ps = con.prepareStatement(sql)) { + ps.setInt(1, bx); + ps.setInt(2, by); + ps.setInt(3, bz); + ps.setInt(4, PAGE_SIZE); + ps.setInt(5, offset); + ResultSet rs = ps.executeQuery(); + plugin.getServer().getScheduler().runTask(plugin, () -> { + try { consumer.accept(rs); } + catch (SQLException ex) { send(s, NamedTextColor.RED, "Database error: " + ex.getMessage()); } + }); + } catch (SQLException ex) { + send(s, NamedTextColor.RED, "Database error: " + ex.getMessage()); + } + }); + } + + // -------------------------------------------------------- + // Helper methods + // -------------------------------------------------------- + + private void requirePerm(CommandSender s, String perm, Runnable action) { + if (!s.hasPermission(perm)) { + send(s, NamedTextColor.RED, "No permission: " + perm); + } else { + action.run(); + } + } + + private int parsePage(String[] args, int idx) { + if (args.length > idx) { + try { return Math.max(1, Integer.parseInt(args[idx])); } + catch (NumberFormatException ignored) {} + } + return 1; + } + + private static final DateTimeFormatter EU_TS = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"); + private static final DateTimeFormatter DB_TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private String fmtTs(String raw) { + if (raw == null) return "-"; + try { + String s = raw.contains(".") ? raw.substring(0, raw.indexOf('.')) : raw; + return LocalDateTime.parse(s, DB_TS).format(EU_TS); + } catch (Exception e) { + return raw; + } + } + + private String formatDuration(long seconds) { + long h = seconds / 3600, m = (seconds % 3600) / 60, sec = seconds % 60; + return String.format("%02d:%02d:%02d", h, m, sec); + } + + private void sendHeader(CommandSender s, String title) { + s.sendMessage(Component.text("══ " + title + " ══") + .color(NamedTextColor.GOLD) + .decorate(TextDecoration.BOLD)); + } + + private void sendLine(CommandSender s, String cmd, String desc) { + s.sendMessage(Component.text(" " + cmd + " ") + .color(NamedTextColor.YELLOW) + .append(Component.text("– " + desc).color(NamedTextColor.GRAY))); + } + + private void send(CommandSender s, NamedTextColor color, String text) { + s.sendMessage(Component.text(text).color(color)); + } +} diff --git a/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/database/DatabaseManager.java b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/database/DatabaseManager.java new file mode 100644 index 0000000..0a7ff6d --- /dev/null +++ b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/database/DatabaseManager.java @@ -0,0 +1,877 @@ +package de.simolzimol.mclogger.paper.database; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import de.simolzimol.mclogger.paper.PaperLoggerPlugin; +import org.bukkit.configuration.file.FileConfiguration; + +import java.sql.*; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; + +/** + * Manages the MariaDB connection pool and provides + * helper methods for writing all log events. + * + * @author SimolZimol + */ +public class DatabaseManager { + + private final PaperLoggerPlugin plugin; + private HikariDataSource dataSource; + private String serverName; + private int serverId = -1; + private static final Gson GSON = new GsonBuilder().create(); + + public DatabaseManager(PaperLoggerPlugin plugin) { + this.plugin = plugin; + } + + // -------------------------------------------------------- + // Connection Lifecycle + // -------------------------------------------------------- + + public boolean connect() { + FileConfiguration cfg = plugin.getConfig(); + serverName = cfg.getString("server.name", "default"); + + HikariConfig hk = new HikariConfig(); + hk.setDriverClassName("de.simolzimol.mclogger.lib.mariadb.jdbc.Driver"); + hk.setJdbcUrl(String.format("jdbc:mariadb://%s:%d/%s?useSSL=%b&autoReconnect=true&characterEncoding=UTF-8", + cfg.getString("database.host", "localhost"), + cfg.getInt("database.port", 3306), + cfg.getString("database.database", "mclogger"), + cfg.getBoolean("database.ssl", false))); + hk.setUsername(cfg.getString("database.username", "root")); + hk.setPassword(cfg.getString("database.password", "")); + hk.setMaximumPoolSize(cfg.getInt("database.pool-size", 10)); + hk.setMinimumIdle(2); + hk.setConnectionTimeout(30_000); + hk.setIdleTimeout(600_000); + hk.setMaxLifetime(1_800_000); + hk.setPoolName("MCLogger-Paper"); + hk.addDataSourceProperty("cachePrepStmts", "true"); + hk.addDataSourceProperty("prepStmtCacheSize", "250"); + hk.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + + try { + dataSource = new HikariDataSource(hk); + initializeTables(); + registerServer(cfg); + plugin.getLogger().info("[MCLogger] Database connected - Server: " + serverName + " (ID: " + serverId + ")"); + return true; + } catch (Exception e) { + plugin.getLogger().log(Level.SEVERE, "[MCLogger] Database connection failed!", e); + return false; + } + } + + private void initializeTables() throws SQLException { + String[] ddl = { + // servers + "CREATE TABLE IF NOT EXISTS servers (" + + " id INT AUTO_INCREMENT PRIMARY KEY," + + " server_name VARCHAR(100) NOT NULL," + + " server_type ENUM('paper','velocity') NOT NULL DEFAULT 'paper'," + + " ip_address VARCHAR(45)," + + " mc_version VARCHAR(100)," + + " first_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " last_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)," + + " UNIQUE KEY uq_server_name (server_name)" + + ") ENGINE=InnoDB", + + // players + "CREATE TABLE IF NOT EXISTS players (" + + " uuid VARCHAR(36) PRIMARY KEY," + + " username VARCHAR(16) NOT NULL," + + " display_name VARCHAR(64)," + + " ip_address VARCHAR(45)," + + " locale VARCHAR(20)," + + " first_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " last_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)," + + " total_playtime_sec BIGINT DEFAULT 0," + + " is_op TINYINT(1) DEFAULT 0," + + " INDEX idx_username (username)" + + ") ENGINE=InnoDB", + + // player_sessions + "CREATE TABLE IF NOT EXISTS player_sessions (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " player_uuid VARCHAR(36) NOT NULL," + + " player_name VARCHAR(16) NOT NULL," + + " server_name VARCHAR(100)," + + " login_time TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " logout_time TIMESTAMP(3) NULL DEFAULT NULL," + + " duration_sec INT," + + " ip_address VARCHAR(45)," + + " country VARCHAR(100)," + + " client_version VARCHAR(20)," + + " INDEX idx_ps_uuid (player_uuid)," + + " INDEX idx_ps_server (server_name)," + + " INDEX idx_ps_login (login_time)" + + ") ENGINE=InnoDB", + + // player_chat + "CREATE TABLE IF NOT EXISTS player_chat (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " player_uuid VARCHAR(36)," + + " player_name VARCHAR(16)," + + " server_name VARCHAR(100)," + + " world VARCHAR(100)," + + " message TEXT NOT NULL," + + " channel VARCHAR(50) DEFAULT 'global'," + + " INDEX idx_chat_uuid (player_uuid)," + + " INDEX idx_chat_timestamp (timestamp)," + + " INDEX idx_chat_server (server_name)," + + " FULLTEXT INDEX ft_message (message)" + + ") ENGINE=InnoDB", + + // player_commands + "CREATE TABLE IF NOT EXISTS player_commands (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " player_uuid VARCHAR(36)," + + " player_name VARCHAR(16)," + + " server_name VARCHAR(100)," + + " world VARCHAR(100)," + + " command TEXT NOT NULL," + + " x DOUBLE," + + " y DOUBLE," + + " z DOUBLE," + + " INDEX idx_cmd_uuid (player_uuid)," + + " INDEX idx_cmd_timestamp (timestamp)," + + " INDEX idx_cmd_server (server_name)" + + ") ENGINE=InnoDB", + + // block_events + "CREATE TABLE IF NOT EXISTS block_events (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " event_type ENUM('break','place','ignite','burn','explode','fade','grow','dispense') NOT NULL," + + " player_uuid VARCHAR(36)," + + " player_name VARCHAR(16)," + + " server_name VARCHAR(100)," + + " world VARCHAR(100) NOT NULL," + + " x INT NOT NULL," + + " y INT NOT NULL," + + " z INT NOT NULL," + + " block_type VARCHAR(100) NOT NULL," + + " block_data VARCHAR(255)," + + " tool VARCHAR(100)," + + " is_silk TINYINT(1) DEFAULT 0," + + " INDEX idx_be_player (player_uuid)," + + " INDEX idx_be_timestamp (timestamp)," + + " INDEX idx_be_world (world)," + + " INDEX idx_be_type (event_type)," + + " INDEX idx_be_server (server_name)" + + ") ENGINE=InnoDB", + + // player_deaths + "CREATE TABLE IF NOT EXISTS player_deaths (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " player_uuid VARCHAR(36) NOT NULL," + + " player_name VARCHAR(16) NOT NULL," + + " server_name VARCHAR(100)," + + " world VARCHAR(100)," + + " x DOUBLE," + + " y DOUBLE," + + " z DOUBLE," + + " death_message TEXT," + + " cause VARCHAR(100)," + + " killer_uuid VARCHAR(36)," + + " killer_name VARCHAR(100)," + + " killer_type VARCHAR(100)," + + " exp_level INT," + + " items_lost JSON," + + " INDEX idx_deaths_uuid (player_uuid)," + + " INDEX idx_deaths_timestamp (timestamp)" + + ") ENGINE=InnoDB", + + // entity_events + "CREATE TABLE IF NOT EXISTS entity_events (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " event_type ENUM('spawn','death','damage','tame','breed','transform','explode') NOT NULL," + + " server_name VARCHAR(100)," + + " world VARCHAR(100)," + + " x DOUBLE," + + " y DOUBLE," + + " z DOUBLE," + + " entity_type VARCHAR(100) NOT NULL," + + " entity_uuid VARCHAR(36)," + + " entity_name VARCHAR(100)," + + " player_uuid VARCHAR(36)," + + " player_name VARCHAR(16)," + + " cause VARCHAR(100)," + + " damage DOUBLE," + + " details JSON," + + " INDEX idx_ee_timestamp (timestamp)," + + " INDEX idx_ee_type (event_type)," + + " INDEX idx_ee_entity (entity_type)," + + " INDEX idx_ee_player (player_uuid)," + + " INDEX idx_ee_server (server_name)" + + ") ENGINE=InnoDB", + + // player_teleports + "CREATE TABLE IF NOT EXISTS player_teleports (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " player_uuid VARCHAR(36) NOT NULL," + + " player_name VARCHAR(16) NOT NULL," + + " server_name VARCHAR(100)," + + " from_world VARCHAR(100)," + + " from_x DOUBLE," + + " from_y DOUBLE," + + " from_z DOUBLE," + + " to_world VARCHAR(100)," + + " to_x DOUBLE," + + " to_y DOUBLE," + + " to_z DOUBLE," + + " cause VARCHAR(100)," + + " INDEX idx_tp_uuid (player_uuid)," + + " INDEX idx_tp_timestamp (timestamp)" + + ") ENGINE=InnoDB", + + // inventory_events + "CREATE TABLE IF NOT EXISTS inventory_events (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " event_type ENUM('pickup','drop','click','craft','enchant','anvil','trade') NOT NULL," + + " player_uuid VARCHAR(36) NOT NULL," + + " player_name VARCHAR(16) NOT NULL," + + " server_name VARCHAR(100)," + + " world VARCHAR(100)," + + " x DOUBLE," + + " y DOUBLE," + + " z DOUBLE," + + " item_type VARCHAR(100)," + + " item_amount INT," + + " item_meta JSON," + + " slot INT," + + " inventory_type VARCHAR(100)," + + " INDEX idx_inv_uuid (player_uuid)," + + " INDEX idx_inv_timestamp (timestamp)," + + " INDEX idx_inv_type (event_type)" + + ") ENGINE=InnoDB", + + // player_stats + "CREATE TABLE IF NOT EXISTS player_stats (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " event_type VARCHAR(100) NOT NULL," + + " player_uuid VARCHAR(36) NOT NULL," + + " player_name VARCHAR(16) NOT NULL," + + " server_name VARCHAR(100)," + + " old_value VARCHAR(255)," + + " new_value VARCHAR(255)," + + " details JSON," + + " INDEX idx_pst_uuid (player_uuid)," + + " INDEX idx_pst_timestamp (timestamp)," + + " INDEX idx_pst_type (event_type)" + + ") ENGINE=InnoDB", + + // world_events + "CREATE TABLE IF NOT EXISTS world_events (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " event_type VARCHAR(100) NOT NULL," + + " server_name VARCHAR(100)," + + " world VARCHAR(100)," + + " x DOUBLE," + + " y DOUBLE," + + " z DOUBLE," + + " details JSON," + + " INDEX idx_we_timestamp (timestamp)," + + " INDEX idx_we_world (world)," + + " INDEX idx_we_type (event_type)" + + ") ENGINE=InnoDB", + + // server_events + "CREATE TABLE IF NOT EXISTS server_events (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " event_type VARCHAR(100) NOT NULL," + + " server_name VARCHAR(100)," + + " message TEXT," + + " details JSON," + + " INDEX idx_se_timestamp (timestamp)," + + " INDEX idx_se_type (event_type)," + + " INDEX idx_se_server (server_name)" + + ") ENGINE=InnoDB", + + // proxy_events + "CREATE TABLE IF NOT EXISTS proxy_events (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " event_type ENUM('login','disconnect','server_switch','command','chat','kick','proxy_start','proxy_stop') NOT NULL," + + " player_uuid VARCHAR(36)," + + " player_name VARCHAR(16)," + + " proxy_name VARCHAR(100)," + + " from_server VARCHAR(100)," + + " to_server VARCHAR(100)," + + " ip_address VARCHAR(45)," + + " details JSON," + + " INDEX idx_pe_uuid (player_uuid)," + + " INDEX idx_pe_timestamp (timestamp)," + + " INDEX idx_pe_type (event_type)," + + " INDEX idx_pe_proxy (proxy_name)" + + ") ENGINE=InnoDB", + + // sign_edits + "CREATE TABLE IF NOT EXISTS sign_edits (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " player_uuid VARCHAR(36)," + + " player_name VARCHAR(16)," + + " server_name VARCHAR(100)," + + " world VARCHAR(100)," + + " x INT," + + " y INT," + + " z INT," + + " line1 VARCHAR(255)," + + " line2 VARCHAR(255)," + + " line3 VARCHAR(255)," + + " line4 VARCHAR(255)," + + " INDEX idx_sign_uuid (player_uuid)," + + " INDEX idx_sign_timestamp (timestamp)" + + ") ENGINE=InnoDB", + + // plugin_events + "CREATE TABLE IF NOT EXISTS plugin_events (" + + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + + " timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + + " event_type VARCHAR(100) NOT NULL," + + " plugin_name VARCHAR(100)," + + " server_name VARCHAR(100)," + + " player_uuid VARCHAR(36)," + + " player_name VARCHAR(16)," + + " actor_uuid VARCHAR(36)," + + " actor_name VARCHAR(64)," + + " target_type VARCHAR(50)," + + " target_id VARCHAR(100)," + + " action VARCHAR(255)," + + " details JSON," + + " INDEX idx_plev_uuid (player_uuid)," + + " INDEX idx_plev_timestamp (timestamp)," + + " INDEX idx_plev_type (event_type)," + + " INDEX idx_plev_plugin (plugin_name)," + + " INDEX idx_plev_actor (actor_uuid)" + + ") ENGINE=InnoDB", + + // view + "CREATE OR REPLACE VIEW v_recent_activity AS" + + " SELECT 'chat' AS source, timestamp, player_name, server_name, message AS detail FROM player_chat WHERE timestamp >= NOW() - INTERVAL 24 HOUR" + + " UNION ALL" + + " SELECT 'command' AS source, timestamp, player_name, server_name, command AS detail FROM player_commands WHERE timestamp >= NOW() - INTERVAL 24 HOUR" + + " UNION ALL" + + " SELECT 'block' AS source, timestamp, player_name, server_name, CONCAT(event_type,' ',block_type,' at ',world,' ',x,',',y,',',z) AS detail FROM block_events WHERE timestamp >= NOW() - INTERVAL 24 HOUR" + + " UNION ALL" + + " SELECT 'death' AS source, timestamp, player_name, server_name, death_message AS detail FROM player_deaths WHERE timestamp >= NOW() - INTERVAL 24 HOUR" + + " ORDER BY timestamp DESC" + }; + + try (Connection con = dataSource.getConnection(); + Statement st = con.createStatement()) { + for (String sql : ddl) { + st.execute(sql); + } + // Widen mc_version in case the table was created with the old VARCHAR(20) + st.execute("ALTER TABLE servers MODIFY COLUMN mc_version VARCHAR(100)"); + // Add country column to existing tables + st.execute("ALTER TABLE player_sessions ADD COLUMN IF NOT EXISTS country VARCHAR(100) AFTER ip_address"); + } + plugin.getLogger().info("[MCLogger] Database tables verified/created."); + } + + public void disconnect() { + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + } + } + + private void registerServer(FileConfiguration cfg) throws SQLException { + String version = plugin.getServer().getMinecraftVersion(); + try (Connection con = dataSource.getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO servers (server_name, server_type, ip_address, mc_version) VALUES (?,?,?,?) " + + "ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP(3), mc_version = VALUES(mc_version)", + Statement.RETURN_GENERATED_KEYS)) { + ps.setString(1, serverName); + ps.setString(2, "paper"); + ps.setString(3, cfg.getString("database.host", "localhost")); + ps.setString(4, version); + ps.executeUpdate(); + try (ResultSet rs = ps.getGeneratedKeys()) { + if (rs.next()) serverId = rs.getInt(1); + } + } + if (serverId == -1) { + try (Connection con = dataSource.getConnection(); + PreparedStatement ps = con.prepareStatement("SELECT id FROM servers WHERE server_name = ?")) { + ps.setString(1, serverName); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) serverId = rs.getInt(1); + } + } + } + } + + public Connection getConnection() throws SQLException { + return dataSource.getConnection(); + } + + // -------------------------------------------------------- + // Async insert helper + // -------------------------------------------------------- + + private void asyncExec(ThrowingRunnable r) { + CompletableFuture.runAsync(() -> { + try { + r.run(); + } catch (Exception e) { + plugin.getLogger().log(Level.WARNING, "[MCLogger] DB write error: " + e.getMessage()); + } + }); + } + + @FunctionalInterface + interface ThrowingRunnable { + void run() throws Exception; + } + + // -------------------------------------------------------- + // Players + // -------------------------------------------------------- + + public void upsertPlayer(String uuid, String username, String displayName, + String ip, String locale, boolean isOp) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO players (uuid, username, display_name, ip_address, locale, is_op) VALUES (?,?,?,?,?,?) " + + "ON DUPLICATE KEY UPDATE username=VALUES(username), display_name=VALUES(display_name), " + + "ip_address=VALUES(ip_address), locale=VALUES(locale), is_op=VALUES(is_op), " + + "last_seen=CURRENT_TIMESTAMP(3)")) { + ps.setString(1, uuid); + ps.setString(2, username); + ps.setString(3, displayName); + ps.setString(4, ip); + ps.setString(5, locale); + ps.setBoolean(6, isOp); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Sessions + // -------------------------------------------------------- + + public void insertSessionLogin(String uuid, String name, String ip, String clientVersion) { + asyncExec(() -> { + String country = lookupCountry(ip); + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO player_sessions (player_uuid, player_name, server_name, ip_address, country, client_version) VALUES (?,?,?,?,?,?)")) { + ps.setString(1, uuid); + ps.setString(2, name); + ps.setString(3, serverName); + ps.setString(4, ip); + ps.setString(5, country); + ps.setString(6, clientVersion); + ps.executeUpdate(); + } + }); + } + + /** Looks up the player's country via ip-api.com (free, no API key). Returns "Local" for LAN/private IPs. */ + private String lookupCountry(String ip) { + if (ip == null || ip.equals("unknown") || ip.equals("::1") + || ip.startsWith("127.") || ip.startsWith("10.") + || ip.startsWith("192.168.") || ip.startsWith("172.")) { + return "Local"; + } + try { + java.net.URL url = new java.net.URL("http://ip-api.com/json/" + ip + "?fields=country"); + java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + conn.setRequestMethod("GET"); + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(conn.getInputStream()))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) sb.append(line); + JsonObject obj = GSON.fromJson(sb.toString(), JsonObject.class); + if (obj != null && obj.has("country")) return obj.get("country").getAsString(); + } + } catch (Exception ignored) {} + return "Unknown"; + } + + public void closeSession(String uuid, long durationSec) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "UPDATE player_sessions SET logout_time=CURRENT_TIMESTAMP(3), duration_sec=? " + + "WHERE player_uuid=? AND server_name=? AND logout_time IS NULL ORDER BY login_time DESC LIMIT 1")) { + ps.setLong(1, durationSec); + ps.setString(2, uuid); + ps.setString(3, serverName); + ps.executeUpdate(); + } + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "UPDATE players SET total_playtime_sec = total_playtime_sec + ? WHERE uuid = ?")) { + ps.setLong(1, durationSec); + ps.setString(2, uuid); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Chat + // -------------------------------------------------------- + + public void insertChat(String uuid, String name, String world, String message, String channel) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO player_chat (player_uuid, player_name, server_name, world, message, channel) VALUES (?,?,?,?,?,?)")) { + ps.setString(1, uuid); + ps.setString(2, name); + ps.setString(3, serverName); + ps.setString(4, world); + ps.setString(5, message); + ps.setString(6, channel); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Commands + // -------------------------------------------------------- + + public void insertCommand(String uuid, String name, String world, + String command, double x, double y, double z) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO player_commands (player_uuid, player_name, server_name, world, command, x, y, z) VALUES (?,?,?,?,?,?,?,?)")) { + ps.setString(1, uuid); + ps.setString(2, name); + ps.setString(3, serverName); + ps.setString(4, world); + ps.setString(5, command); + ps.setDouble(6, x); + ps.setDouble(7, y); + ps.setDouble(8, z); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Block Events + // -------------------------------------------------------- + + public void insertBlockEvent(String type, String uuid, String name, + String world, int x, int y, int z, + String blockType, String blockData, String tool, boolean silkTouch) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO block_events (event_type, player_uuid, player_name, server_name, world, x, y, z, block_type, block_data, tool, is_silk) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)")) { + ps.setString(1, type); + ps.setString(2, uuid); + ps.setString(3, name); + ps.setString(4, serverName); + ps.setString(5, world); + ps.setInt(6, x); + ps.setInt(7, y); + ps.setInt(8, z); + ps.setString(9, blockType); + ps.setString(10, blockData); + ps.setString(11, tool); + ps.setBoolean(12, silkTouch); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Deaths + // -------------------------------------------------------- + + public void insertDeath(String uuid, String name, String world, double x, double y, double z, + String msg, String cause, String killerUuid, String killerName, + String killerType, int expLevel, Map itemsLost) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO player_deaths (player_uuid, player_name, server_name, world, x, y, z, " + + "death_message, cause, killer_uuid, killer_name, killer_type, exp_level, items_lost) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)")) { + ps.setString(1, uuid); + ps.setString(2, name); + ps.setString(3, serverName); + ps.setString(4, world); + ps.setDouble(5, x); + ps.setDouble(6, y); + ps.setDouble(7, z); + ps.setString(8, msg); + ps.setString(9, cause); + ps.setString(10, killerUuid); + ps.setString(11, killerName); + ps.setString(12, killerType); + ps.setInt(13, expLevel); + ps.setString(14, itemsLost != null ? GSON.toJson(itemsLost) : null); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Entity Events + // -------------------------------------------------------- + + public void insertEntityEvent(String type, String world, double x, double y, double z, + String entityType, String entityUuid, String entityName, + String playerUuid, String playerName, String cause, + double damage, Map details) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO entity_events (event_type, server_name, world, x, y, z, entity_type, entity_uuid, entity_name, player_uuid, player_name, cause, damage, details) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)")) { + ps.setString(1, type); + ps.setString(2, serverName); + ps.setString(3, world); + ps.setDouble(4, x); + ps.setDouble(5, y); + ps.setDouble(6, z); + ps.setString(7, entityType); + ps.setString(8, entityUuid); + ps.setString(9, entityName); + ps.setString(10, playerUuid); + ps.setString(11, playerName); + ps.setString(12, cause); + ps.setDouble(13, damage); + ps.setString(14, details != null ? GSON.toJson(details) : null); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Teleports + // -------------------------------------------------------- + + public void insertTeleport(String uuid, String name, + String fromWorld, double fx, double fy, double fz, + String toWorld, double tx, double ty, double tz, + String cause) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO player_teleports (player_uuid, player_name, server_name, from_world, from_x, from_y, from_z, to_world, to_x, to_y, to_z, cause) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)")) { + ps.setString(1, uuid); + ps.setString(2, name); + ps.setString(3, serverName); + ps.setString(4, fromWorld); + ps.setDouble(5, fx); + ps.setDouble(6, fy); + ps.setDouble(7, fz); + ps.setString(8, toWorld); + ps.setDouble(9, tx); + ps.setDouble(10, ty); + ps.setDouble(11, tz); + ps.setString(12, cause); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Inventory Events + // -------------------------------------------------------- + + public void insertInventoryEvent(String type, String uuid, String name, + String world, double x, double y, double z, + String item, int amount, Map meta, + int slot, String invType) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO inventory_events (event_type, player_uuid, player_name, server_name, world, x, y, z, item_type, item_amount, item_meta, slot, inventory_type) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)")) { + ps.setString(1, type); + ps.setString(2, uuid); + ps.setString(3, name); + ps.setString(4, serverName); + ps.setString(5, world); + ps.setDouble(6, x); + ps.setDouble(7, y); + ps.setDouble(8, z); + ps.setString(9, item); + ps.setInt(10, amount); + ps.setString(11, meta != null ? GSON.toJson(meta) : null); + ps.setInt(12, slot); + ps.setString(13, invType); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Player Stats (Gamemode, Level, etc.) + // -------------------------------------------------------- + + public void insertPlayerStat(String type, String uuid, String name, + String oldVal, String newVal, Map details) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO player_stats (event_type, player_uuid, player_name, server_name, old_value, new_value, details) VALUES (?,?,?,?,?,?,?)")) { + ps.setString(1, type); + ps.setString(2, uuid); + ps.setString(3, name); + ps.setString(4, serverName); + ps.setString(5, oldVal); + ps.setString(6, newVal); + ps.setString(7, details != null ? GSON.toJson(details) : null); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // World Events + // -------------------------------------------------------- + + public void insertWorldEvent(String type, String world, Double x, Double y, Double z, Map details) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO world_events (event_type, server_name, world, x, y, z, details) VALUES (?,?,?,?,?,?,?)")) { + ps.setString(1, type); + ps.setString(2, serverName); + ps.setString(3, world); + if (x != null) ps.setDouble(4, x); else ps.setNull(4, Types.DOUBLE); + if (y != null) ps.setDouble(5, y); else ps.setNull(5, Types.DOUBLE); + if (z != null) ps.setDouble(6, z); else ps.setNull(6, Types.DOUBLE); + ps.setString(7, details != null ? GSON.toJson(details) : null); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Server Events + // -------------------------------------------------------- + + public void insertServerEvent(String type, String message, Map details) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO server_events (event_type, server_name, message, details) VALUES (?,?,?,?)")) { + ps.setString(1, type); + ps.setString(2, serverName); + ps.setString(3, message); + ps.setString(4, details != null ? GSON.toJson(details) : null); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Sign Edits + // -------------------------------------------------------- + + public void insertSignEdit(String uuid, String name, String world, + int x, int y, int z, + String l1, String l2, String l3, String l4) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO sign_edits (player_uuid, player_name, server_name, world, x, y, z, line1, line2, line3, line4) VALUES (?,?,?,?,?,?,?,?,?,?,?)")) { + ps.setString(1, uuid); + ps.setString(2, name); + ps.setString(3, serverName); + ps.setString(4, world); + ps.setInt(5, x); + ps.setInt(6, y); + ps.setInt(7, z); + ps.setString(8, l1); + ps.setString(9, l2); + ps.setString(10, l3); + ps.setString(11, l4); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Plugin Events (LuckPerms etc.) + // -------------------------------------------------------- + + /** + * Writes a generic plugin event (e.g. LuckPerms permission change). + * + * @param eventType Type of event (e.g. "luckperms_grant", "luckperms_revoke") + * @param pluginName Name of the triggering plugin (e.g. "LuckPerms") + * @param playerUuid UUID of the affected player (may be null) + * @param playerName Name of the affected player + * @param actorUuid UUID of the actor (console UUID = 00000000-...) + * @param actorName Display name of the actor + * @param targetType "user" or "group" + * @param targetId UUID or group name + * @param action Description of the action (e.g. "permission.node set to true") + * @param details Additional details as Map + */ + public void insertPluginEvent(String eventType, String pluginName, + String playerUuid, String playerName, + String actorUuid, String actorName, + String targetType, String targetId, + String action, Map details) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO plugin_events " + + "(event_type, plugin_name, server_name, player_uuid, player_name, " + + " actor_uuid, actor_name, target_type, target_id, action, details) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?)")) { + ps.setString(1, eventType); + ps.setString(2, pluginName); + ps.setString(3, serverName); + if (playerUuid != null) ps.setString(4, playerUuid); else ps.setNull(4, Types.VARCHAR); + if (playerName != null) ps.setString(5, playerName); else ps.setNull(5, Types.VARCHAR); + if (actorUuid != null) ps.setString(6, actorUuid); else ps.setNull(6, Types.VARCHAR); + if (actorName != null) ps.setString(7, actorName); else ps.setNull(7, Types.VARCHAR); + if (targetType != null) ps.setString(8, targetType); else ps.setNull(8, Types.VARCHAR); + if (targetId != null) ps.setString(9, targetId); else ps.setNull(9, Types.VARCHAR); + if (action != null) ps.setString(10, action); else ps.setNull(10, Types.VARCHAR); + ps.setString(11, details != null ? GSON.toJson(details) : null); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Getter + // -------------------------------------------------------- + + public String getServerName() { return serverName; } + public int getServerId() { return serverId; } + public boolean isConnected() { return dataSource != null && !dataSource.isClosed(); } +} diff --git a/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/BlockListener.java b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/BlockListener.java new file mode 100644 index 0000000..c09cea8 --- /dev/null +++ b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/BlockListener.java @@ -0,0 +1,162 @@ +package de.simolzimol.mclogger.paper.listeners; + +import de.simolzimol.mclogger.paper.PaperLoggerPlugin; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.block.Block; +import org.bukkit.block.Sign; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.*; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.HashMap; +import java.util.Map; + +/** + * Logs all block-related events: breaking, placing, + * burning, exploding, igniting, signs, etc. + * + * @author SimolZimol + */ +public class BlockListener implements Listener { + + private final PaperLoggerPlugin plugin; + + public BlockListener(PaperLoggerPlugin plugin) { + this.plugin = plugin; + } + + // ------------------------------------------------------- + // Block Break + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBreak(BlockBreakEvent event) { + Player p = event.getPlayer(); + Block b = event.getBlock(); + ItemStack tool = p.getInventory().getItemInMainHand(); + + boolean silkTouch = tool.hasItemMeta() + && tool.getItemMeta() != null + && tool.getItemMeta().hasEnchant(Enchantment.SILK_TOUCH); + + String toolName = tool.getType().isAir() ? "HAND" : tool.getType().name(); + String blockData = b.getBlockData().getAsString(); + + plugin.getDb().insertBlockEvent( + "break", + p.getUniqueId().toString(), + p.getName(), + b.getWorld().getName(), + b.getX(), b.getY(), b.getZ(), + b.getType().name(), + blockData, + toolName, + silkTouch + ); + } + + // ------------------------------------------------------- + // Block Place + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlace(BlockPlaceEvent event) { + Player p = event.getPlayer(); + Block b = event.getBlockPlaced(); + + plugin.getDb().insertBlockEvent( + "place", + p.getUniqueId().toString(), + p.getName(), + b.getWorld().getName(), + b.getX(), b.getY(), b.getZ(), + b.getType().name(), + b.getBlockData().getAsString(), + null, false + ); + } + + // ------------------------------------------------------- + // Block Burn + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBurn(BlockBurnEvent event) { + Block b = event.getBlock(); + plugin.getDb().insertBlockEvent( + "burn", + null, null, + b.getWorld().getName(), + b.getX(), b.getY(), b.getZ(), + b.getType().name(), + b.getBlockData().getAsString(), + null, false + ); + } + + // ------------------------------------------------------- + // Block Ignite + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onIgnite(BlockIgniteEvent event) { + Block b = event.getBlock(); + Player p = event.getPlayer(); + + Map details = new HashMap<>(); + details.put("cause", event.getCause().name()); + + plugin.getDb().insertBlockEvent( + "ignite", + p != null ? p.getUniqueId().toString() : null, + p != null ? p.getName() : null, + b.getWorld().getName(), + b.getX(), b.getY(), b.getZ(), + b.getType().name(), + b.getBlockData().getAsString(), + null, false + ); + } + + // ------------------------------------------------------- + // Block Explosion + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onExplode(BlockExplodeEvent event) { + Block b = event.getBlock(); + plugin.getDb().insertBlockEvent( + "explode", + null, null, + b.getWorld().getName(), + b.getX(), b.getY(), b.getZ(), + b.getType().name(), + b.getBlockData().getAsString(), + null, false + ); + } + + // ------------------------------------------------------- + // Sign Edit + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onSign(SignChangeEvent event) { + Player p = event.getPlayer(); + Block b = event.getBlock(); + + String[] lines = new String[4]; + for (int i = 0; i < 4; i++) { + lines[i] = event.line(i) != null + ? PlainTextComponentSerializer.plainText().serialize(event.line(i)) + : ""; + } + + plugin.getDb().insertSignEdit( + p.getUniqueId().toString(), + p.getName(), + b.getWorld().getName(), + b.getX(), b.getY(), b.getZ(), + lines[0], lines[1], lines[2], lines[3] + ); + } +} diff --git a/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/EntityListener.java b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/EntityListener.java new file mode 100644 index 0000000..8b31d00 --- /dev/null +++ b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/EntityListener.java @@ -0,0 +1,206 @@ +package de.simolzimol.mclogger.paper.listeners; + +import de.simolzimol.mclogger.paper.PaperLoggerPlugin; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.entity.*; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * Logs entity events: spawns, deaths, damage, taming, breeding, explosions. + * + * @author SimolZimol + */ +public class EntityListener implements Listener { + + private final PaperLoggerPlugin plugin; + + public EntityListener(PaperLoggerPlugin plugin) { + this.plugin = plugin; + } + + // ------------------------------------------------------- + // Entity Spawn + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onSpawn(EntitySpawnEvent event) { + Entity e = event.getEntity(); + // Skip redundant entities (projectiles etc.) to reduce DB load + if (e instanceof Item || e instanceof ExperienceOrb || e instanceof Arrow) return; + + String playerUuid = null, playerName = null; + if (e instanceof Player p) { + playerUuid = p.getUniqueId().toString(); + playerName = p.getName(); + } + + Map details = new HashMap<>(); + details.put("spawn_reason", e instanceof LivingEntity le ? "natural" : "other"); + details.put("has_ai", e instanceof Mob mob && mob.hasAI()); + + plugin.getDb().insertEntityEvent( + "spawn", + e.getWorld().getName(), + e.getLocation().getX(), e.getLocation().getY(), e.getLocation().getZ(), + e.getType().name(), + e.getUniqueId().toString(), + e.customName() != null ? PlainTextComponentSerializer.plainText().serialize(e.customName()) : null, + playerUuid, playerName, + null, 0, details + ); + } + + // ------------------------------------------------------- + // Entity Death + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDeath(EntityDeathEvent event) { + LivingEntity e = event.getEntity(); + if (e instanceof Player) return; // Player deaths are handled by PlayerDeathListener + + Player killer = e.getKiller(); + String killerUuid = killer != null ? killer.getUniqueId().toString() : null; + String killerName = killer != null ? killer.getName() : null; + + String cause = e.getLastDamageCause() != null + ? e.getLastDamageCause().getCause().name() : "UNKNOWN"; + + Map details = new HashMap<>(); + details.put("drops", event.getDrops().size()); + details.put("exp", event.getDroppedExp()); + + plugin.getDb().insertEntityEvent( + "death", + e.getWorld().getName(), + e.getLocation().getX(), e.getLocation().getY(), e.getLocation().getZ(), + e.getType().name(), + e.getUniqueId().toString(), + e.customName() != null ? PlainTextComponentSerializer.plainText().serialize(e.customName()) : null, + killerUuid, killerName, + cause, 0, details + ); + } + + // ------------------------------------------------------- + // Entity Damage (player-inflicted only) + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDamage(EntityDamageByEntityEvent event) { + if (!(event.getDamager() instanceof Player p)) return; + if (event.getEntity() instanceof Player) return; // PvP separat nicht nötig + Entity target = event.getEntity(); + + plugin.getDb().insertEntityEvent( + "damage", + target.getWorld().getName(), + target.getLocation().getX(), target.getLocation().getY(), target.getLocation().getZ(), + target.getType().name(), + target.getUniqueId().toString(), + target.customName() != null ? PlainTextComponentSerializer.plainText().serialize(target.customName()) : null, + p.getUniqueId().toString(), + p.getName(), + event.getCause().name(), + event.getFinalDamage(), + null + ); + } + + // ------------------------------------------------------- + // Entity Tame + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTame(EntityTameEvent event) { + if (!(event.getOwner() instanceof Player p)) return; + Entity e = event.getEntity(); + + plugin.getDb().insertEntityEvent( + "tame", + e.getWorld().getName(), + e.getLocation().getX(), e.getLocation().getY(), e.getLocation().getZ(), + e.getType().name(), + e.getUniqueId().toString(), + null, + p.getUniqueId().toString(), + p.getName(), + null, 0, null + ); + } + + // ------------------------------------------------------- + // Entity Breed + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBreed(EntityBreedEvent event) { + Entity e = event.getEntity(); + Player breeder = event.getBreeder() instanceof Player pl ? pl : null; + + Map details = new HashMap<>(); + details.put("mother", event.getMother().getType().name()); + details.put("father", event.getFather().getType().name()); + if (event.getBredWith() != null) details.put("bred_with", event.getBredWith().getType().name()); + + plugin.getDb().insertEntityEvent( + "breed", + e.getWorld().getName(), + e.getLocation().getX(), e.getLocation().getY(), e.getLocation().getZ(), + e.getType().name(), + e.getUniqueId().toString(), + null, + breeder != null ? breeder.getUniqueId().toString() : null, + breeder != null ? breeder.getName() : null, + null, 0, details + ); + } + + // ------------------------------------------------------- + // Explosion (Creeper, TNT, etc.) + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onExplode(EntityExplodeEvent event) { + Entity e = event.getEntity(); + + Map details = new HashMap<>(); + details.put("blocks_destroyed", event.blockList().size()); + details.put("yield", event.getYield()); + + plugin.getDb().insertEntityEvent( + "explode", + e.getWorld().getName(), + e.getLocation().getX(), e.getLocation().getY(), e.getLocation().getZ(), + e.getType().name(), + e.getUniqueId().toString(), + null, + null, null, + null, 0, details + ); + } + + // ------------------------------------------------------- + // PvP – Damage between players + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPvP(EntityDamageByEntityEvent event) { + if (!(event.getEntity() instanceof Player victim)) return; + if (!(event.getDamager() instanceof Player attacker)) return; + + Map details = new HashMap<>(); + details.put("cause", event.getCause().name()); + details.put("damage", event.getFinalDamage()); + details.put("victim_health_after", + Math.max(0, victim.getHealth() - event.getFinalDamage())); + + plugin.getDb().insertPlayerStat( + "pvp_hit", + attacker.getUniqueId().toString(), + attacker.getName(), + attacker.getName(), + victim.getName(), + details + ); + } +} diff --git a/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/InventoryListener.java b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/InventoryListener.java new file mode 100644 index 0000000..0bad28d --- /dev/null +++ b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/InventoryListener.java @@ -0,0 +1,124 @@ +package de.simolzimol.mclogger.paper.listeners; + +import de.simolzimol.mclogger.paper.PaperLoggerPlugin; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.HashMap; +import java.util.Map; + +/** + * Logs inventory events: item pickup, item drop, + * crafting, enchanting, trading, etc. + * + * @author SimolZimol + */ +public class InventoryListener implements Listener { + + private final PaperLoggerPlugin plugin; + + public InventoryListener(PaperLoggerPlugin plugin) { + this.plugin = plugin; + } + + // ------------------------------------------------------- + // Item Pickup + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPickup(EntityPickupItemEvent event) { + if (!(event.getEntity() instanceof Player p)) return; + ItemStack item = event.getItem().getItemStack(); + + plugin.getDb().insertInventoryEvent( + "pickup", + p.getUniqueId().toString(), p.getName(), + p.getWorld().getName(), + p.getLocation().getX(), p.getLocation().getY(), p.getLocation().getZ(), + item.getType().name(), item.getAmount(), + buildItemMeta(item), -1, + "player" + ); + } + + // ------------------------------------------------------- + // Item Drop + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDrop(PlayerDropItemEvent event) { + Player p = event.getPlayer(); + ItemStack item = event.getItemDrop().getItemStack(); + + plugin.getDb().insertInventoryEvent( + "drop", + p.getUniqueId().toString(), p.getName(), + p.getWorld().getName(), + p.getLocation().getX(), p.getLocation().getY(), p.getLocation().getZ(), + item.getType().name(), item.getAmount(), + buildItemMeta(item), -1, + "player" + ); + } + + // ------------------------------------------------------- + // Inventory Clicks (player inventories only) + // ------------------------------------------------------- + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player p)) return; + if (event.getCurrentItem() == null) return; + // Only crafting, enchanting, anvil -> trade, etc. + InventoryType type = event.getInventory().getType(); + if (type == InventoryType.PLAYER || type == InventoryType.CREATIVE) return; // too much noise + + ItemStack item = event.getCurrentItem(); + String invTypeName = type.name(); + String logType = switch (type) { + case CRAFTING, WORKBENCH -> "craft"; + case ENCHANTING -> "enchant"; + case ANVIL -> "anvil"; + case MERCHANT -> "trade"; + default -> "click"; + }; + + plugin.getDb().insertInventoryEvent( + logType, + p.getUniqueId().toString(), p.getName(), + p.getWorld().getName(), + p.getLocation().getX(), p.getLocation().getY(), p.getLocation().getZ(), + item.getType().name(), item.getAmount(), + buildItemMeta(item), + event.getSlot(), + invTypeName + ); + } + + // ------------------------------------------------------- + // Helper method: item meta as Map + // ------------------------------------------------------- + private Map buildItemMeta(ItemStack item) { + if (!item.hasItemMeta()) return null; + ItemMeta meta = item.getItemMeta(); + Map m = new HashMap<>(); + if (meta.hasDisplayName() && meta.displayName() != null) { + m.put("name", PlainTextComponentSerializer.plainText().serialize(meta.displayName())); + } + if (meta.hasEnchants()) { + Map enc = new HashMap<>(); + meta.getEnchants().forEach((e, lvl) -> enc.put(e.getKey().getKey(), lvl)); + m.put("enchants", enc); + } + if (meta.hasLore() && meta.lore() != null) { + m.put("lore_lines", meta.lore().size()); + } + if (meta.isUnbreakable()) m.put("unbreakable", true); + return m.isEmpty() ? null : m; + } +} diff --git a/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/LuckPermsListener.java b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/LuckPermsListener.java new file mode 100644 index 0000000..ae81a19 --- /dev/null +++ b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/LuckPermsListener.java @@ -0,0 +1,125 @@ +package de.simolzimol.mclogger.paper.listeners; + +import de.simolzimol.mclogger.paper.PaperLoggerPlugin; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.event.EventBus; +import net.luckperms.api.event.log.LogPublishEvent; +import net.luckperms.api.actionlog.Action; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Listens for LuckPerms log events and stores all permission changes. + * Only registered when LuckPerms is loaded (softdepend). + * + * @author SimolZimol + */ +public class LuckPermsListener { + + private final PaperLoggerPlugin plugin; + + public LuckPermsListener(PaperLoggerPlugin plugin, LuckPerms luckPerms) { + this.plugin = plugin; + registerEvents(luckPerms.getEventBus()); + plugin.getLogger().info("[MCLogger] LuckPerms listener registered."); + } + + private void registerEvents(EventBus bus) { + bus.subscribe(plugin, LogPublishEvent.class, this::onLogPublish); + } + + /** + * LogPublishEvent is fired by LuckPerms whenever an action is written to + * the action log (grant, revoke, group create/delete, track + * add/remove, meta set, etc.). + */ + private void onLogPublish(LogPublishEvent event) { + Action entry = event.getEntry(); + + String actorUuid = entry.getSource().getUniqueId().toString(); + String actorName = entry.getSource().getName(); + String targetType = formatTargetType(entry.getTarget().getType()); + String targetId = formatTargetId(entry.getTarget()); + String actionStr = entry.getDescription(); + + // Derive player UUID if the target is a user + String playerUuid = null; + String playerName = null; + if (entry.getTarget().getType() == Action.Target.Type.USER) { + playerUuid = entry.getTarget().getUniqueId() + .map(UUID::toString).orElse(null); + playerName = entry.getTarget().getName(); + } + + // Derive event type from the action description + String eventType = deriveEventType(actionStr); + + Map details = new HashMap<>(); + details.put("actor_uuid", actorUuid); + details.put("actor_name", actorName); + details.put("target_type", targetType); + details.put("target_id", targetId); + details.put("action_raw", actionStr); + details.put("timestamp_luckperms", entry.getTimestamp().getEpochSecond()); + + plugin.getDb().insertPluginEvent( + eventType, + "LuckPerms", + playerUuid, + playerName, + actorUuid, + actorName, + targetType, + targetId, + actionStr, + details + ); + } + + // -------------------------------------------------------- + // Helper methods + // -------------------------------------------------------- + + private String formatTargetType(Action.Target.Type type) { + return switch (type) { + case USER -> "user"; + case GROUP -> "group"; + case TRACK -> "track"; + }; + } + + private String formatTargetId(Action.Target target) { + if (target.getUniqueId().isPresent()) { + return target.getUniqueId().get().toString(); + } + return target.getName(); + } + + /** + * Maps an action description to a compact event type. + * Beispiele: + * "permission set permission.node to true" -> luckperms_permission_set + * "permission unset permission.node" -> luckperms_permission_unset + * "parent add groupname" -> luckperms_parent_add + * "group create groupname" -> luckperms_group_create + */ + private String deriveEventType(String action) { + if (action == null) return "luckperms_unknown"; + String lower = action.toLowerCase(); + if (lower.startsWith("permission set")) return "luckperms_permission_set"; + if (lower.startsWith("permission unset")) return "luckperms_permission_unset"; + if (lower.startsWith("parent add")) return "luckperms_parent_add"; + if (lower.startsWith("parent remove")) return "luckperms_parent_remove"; + if (lower.startsWith("meta set")) return "luckperms_meta_set"; + if (lower.startsWith("meta unset")) return "luckperms_meta_unset"; + if (lower.startsWith("group create")) return "luckperms_group_create"; + if (lower.startsWith("group delete")) return "luckperms_group_delete"; + if (lower.startsWith("track create")) return "luckperms_track_create"; + if (lower.startsWith("track delete")) return "luckperms_track_delete"; + if (lower.startsWith("track add")) return "luckperms_track_add"; + if (lower.startsWith("track remove")) return "luckperms_track_remove"; + return "luckperms_action"; + } +} diff --git a/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerChatCommandListener.java b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerChatCommandListener.java new file mode 100644 index 0000000..22748b4 --- /dev/null +++ b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerChatCommandListener.java @@ -0,0 +1,69 @@ +package de.simolzimol.mclogger.paper.listeners; + +import de.simolzimol.mclogger.paper.PaperLoggerPlugin; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Logs commands executed by players. + * Chat is logged by the Velocity proxy with correct server context. + * + * @author SimolZimol + */ +public class PlayerChatCommandListener implements Listener { + + private final PaperLoggerPlugin plugin; + + public PlayerChatCommandListener(PaperLoggerPlugin plugin) { + this.plugin = plugin; + } + + /** + * Command – logs with world/position, then notifies Velocity proxy. + * Velocity will skip its own fallback log once it receives the plugin message. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onCommand(PlayerCommandPreprocessEvent event) { + Player p = event.getPlayer(); + String cmd = event.getMessage(); // includes leading '/' + plugin.getDb().insertCommand( + p.getUniqueId().toString(), + p.getName(), + p.getWorld().getName(), + cmd, + p.getLocation().getX(), + p.getLocation().getY(), + p.getLocation().getZ() + ); + // Notify Velocity proxy so it skips duplicate fallback logging + String key = p.getUniqueId() + "|" + cmd; + p.sendPluginMessage(plugin, "mclogger:logged", key.getBytes(StandardCharsets.UTF_8)); + } + + /** + * World change (e.g. Nether / End portal) + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onWorldChange(PlayerChangedWorldEvent event) { + Player p = event.getPlayer(); + Map details = new HashMap<>(); + details.put("from_world", event.getFrom().getName()); + details.put("to_world", p.getWorld().getName()); + plugin.getDb().insertPlayerStat( + "world_change", + p.getUniqueId().toString(), + p.getName(), + event.getFrom().getName(), + p.getWorld().getName(), + details + ); + } +} diff --git a/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerDeathListener.java b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerDeathListener.java new file mode 100644 index 0000000..d50806c --- /dev/null +++ b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerDeathListener.java @@ -0,0 +1,106 @@ +package de.simolzimol.mclogger.paper.listeners; + +import de.simolzimol.mclogger.paper.PaperLoggerPlugin; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.EntityEffect; +import org.bukkit.entity.*; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.inventory.ItemStack; + +import java.util.*; + +/** + * Logs player deaths with full context: + * - cause of death, killer, lost items, XP level + * + * @author SimolZimol + */ +public class PlayerDeathListener implements Listener { + + private final PaperLoggerPlugin plugin; + + public PlayerDeathListener(PaperLoggerPlugin plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDeath(PlayerDeathEvent event) { + Player p = event.getPlayer(); + + String deathMsg = event.deathMessage() != null + ? PlainTextComponentSerializer.plainText().serialize(event.deathMessage()) + : "No death message"; + + // Cause of death + String cause = "UNKNOWN"; + if (p.getLastDamageCause() != null) { + cause = p.getLastDamageCause().getCause().name(); + } + + // Determine killer + String killerUuid = null, killerName = null, killerType = null; + Entity killer = p.getKiller(); + if (killer != null) { + killerType = killer.getType().name(); + killerName = killer instanceof Player + ? ((Player) killer).getName() + : (killer.customName() != null + ? PlainTextComponentSerializer.plainText().serialize(killer.customName()) + : killer.getType().name()); + killerUuid = killer.getUniqueId().toString(); + } else if (p.getLastDamageCause() != null) { + killerType = p.getLastDamageCause().getCause().name(); + } + + // Serialize lost items + Map itemsLost = new LinkedHashMap<>(); + List> items = new ArrayList<>(); + for (ItemStack item : event.getDrops()) { + if (item == null) continue; + Map itemMap = new HashMap<>(); + itemMap.put("type", item.getType().name()); + itemMap.put("amount", item.getAmount()); + if (item.hasItemMeta() && item.getItemMeta().hasDisplayName()) { + itemMap.put("name", PlainTextComponentSerializer.plainText() + .serialize(item.getItemMeta().displayName())); + } + items.add(itemMap); + } + itemsLost.put("items", items); + + plugin.getDb().insertDeath( + p.getUniqueId().toString(), + p.getName(), + p.getWorld().getName(), + p.getLocation().getX(), + p.getLocation().getY(), + p.getLocation().getZ(), + deathMsg, + cause, + killerUuid, + killerName, + killerType, + p.getLevel(), + itemsLost + ); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onRespawn(PlayerRespawnEvent event) { + Player p = event.getPlayer(); + Map details = new HashMap<>(); + details.put("is_anchor_spawn", event.isAnchorSpawn()); + details.put("is_bed_spawn", event.isBedSpawn()); + details.put("world", event.getRespawnLocation().getWorld().getName()); + plugin.getDb().insertPlayerStat( + "respawn", + p.getUniqueId().toString(), + p.getName(), + null, null, details + ); + } +} diff --git a/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerMiscListener.java b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerMiscListener.java new file mode 100644 index 0000000..8291f99 --- /dev/null +++ b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerMiscListener.java @@ -0,0 +1,151 @@ +package de.simolzimol.mclogger.paper.listeners; + +import de.simolzimol.mclogger.paper.PaperLoggerPlugin; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.*; +import org.bukkit.event.player.PlayerBedEnterEvent; + +import java.util.HashMap; +import java.util.Map; + +/** + * Miscellaneous player events: teleport, gamemode, bed, level, + * arrow-shooting (as projectile), hand-swap, sleeping, etc. + * + * @author SimolZimol + */ +public class PlayerMiscListener implements Listener { + + private final PaperLoggerPlugin plugin; + + public PlayerMiscListener(PaperLoggerPlugin plugin) { + this.plugin = plugin; + } + + /** Teleport (all causes) */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTeleport(PlayerTeleportEvent event) { + Player p = event.getPlayer(); + if (event.getFrom().equals(event.getTo())) return; // no movement + + String cause = event.getCause().name(); + // Only log relevant causes (COMMAND, PLUGIN, NETHER_PORTAL, etc.) + plugin.getDb().insertTeleport( + p.getUniqueId().toString(), + p.getName(), + event.getFrom().getWorld().getName(), + event.getFrom().getX(), event.getFrom().getY(), event.getFrom().getZ(), + event.getTo().getWorld().getName(), + event.getTo().getX(), event.getTo().getY(), event.getTo().getZ(), + cause + ); + } + + /** Gamemode change */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onGameMode(PlayerGameModeChangeEvent event) { + Player p = event.getPlayer(); + Map details = new HashMap<>(); + details.put("cause", event.getCause().name()); + plugin.getDb().insertPlayerStat( + "gamemode_change", + p.getUniqueId().toString(), + p.getName(), + p.getGameMode().name(), + event.getNewGameMode().name(), + details + ); + } + + /** Level change (XP) */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onLevelChange(PlayerLevelChangeEvent event) { + if (Math.abs(event.getNewLevel() - event.getOldLevel()) == 0) return; + Player p = event.getPlayer(); + plugin.getDb().insertPlayerStat( + "level_change", + p.getUniqueId().toString(), + p.getName(), + String.valueOf(event.getOldLevel()), + String.valueOf(event.getNewLevel()), + null + ); + } + + /** Enter bed */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBedEnter(PlayerBedEnterEvent event) { + Player p = event.getPlayer(); + Map details = new HashMap<>(); + details.put("result", event.getBedEnterResult().name()); + details.put("world", event.getBed().getWorld().getName()); + details.put("x", event.getBed().getX()); + details.put("y", event.getBed().getY()); + details.put("z", event.getBed().getZ()); + plugin.getDb().insertPlayerStat("bed_enter", p.getUniqueId().toString(), p.getName(), null, null, details); + } + + /** Leave bed */ + @EventHandler(priority = EventPriority.MONITOR) + public void onBedLeave(PlayerBedLeaveEvent event) { + Player p = event.getPlayer(); + Map details = new HashMap<>(); + details.put("world", event.getBed().getWorld().getName()); + plugin.getDb().insertPlayerStat("bed_leave", p.getUniqueId().toString(), p.getName(), null, null, details); + } + + /** Item held change (hand slot) */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onItemHeld(PlayerItemHeldEvent event) { + Player p = event.getPlayer(); + Map details = new HashMap<>(); + details.put("from_slot", event.getPreviousSlot()); + details.put("to_slot", event.getNewSlot()); + String newItem = p.getInventory().getItem(event.getNewSlot()) != null + ? p.getInventory().getItem(event.getNewSlot()).getType().name() : "EMPTY"; + details.put("new_item", newItem); + plugin.getDb().insertPlayerStat("item_held_change", p.getUniqueId().toString(), p.getName(), + String.valueOf(event.getPreviousSlot()), String.valueOf(event.getNewSlot()), details); + } + + /** Swap hands (F key) */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onSwapHands(PlayerSwapHandItemsEvent event) { + Player p = event.getPlayer(); + Map details = new HashMap<>(); + if (event.getMainHandItem() != null) details.put("main_hand", event.getMainHandItem().getType().name()); + if (event.getOffHandItem() != null) details.put("off_hand", event.getOffHandItem().getType().name()); + plugin.getDb().insertPlayerStat("swap_hands", p.getUniqueId().toString(), p.getName(), null, null, details); + } + + /** Fishing activity */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onFish(PlayerFishEvent event) { + Player p = event.getPlayer(); + Map details = new HashMap<>(); + details.put("state", event.getState().name()); + if (event.getCaught() != null) details.put("caught", event.getCaught().getType().name()); + details.put("exp", event.getExpToDrop()); + plugin.getDb().insertPlayerStat("fish", p.getUniqueId().toString(), p.getName(), null, null, details); + } + + /** Player interaction with entity */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInteractEntity(PlayerInteractEntityEvent event) { + Player p = event.getPlayer(); + Map details = new HashMap<>(); + details.put("entity_type", event.getRightClicked().getType().name()); + details.put("entity_uuid", event.getRightClicked().getUniqueId().toString()); + details.put("hand", event.getHand().name()); + plugin.getDb().insertPlayerStat("interact_entity", p.getUniqueId().toString(), p.getName(), null, null, details); + } + + /** OP status changed */ + @EventHandler(priority = EventPriority.MONITOR) + public void onOp(PlayerToggleSneakEvent event) { + // used for OP-change – updated via upsertPlayer on the next join + } +} diff --git a/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerSessionListener.java b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerSessionListener.java new file mode 100644 index 0000000..667f13c --- /dev/null +++ b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerSessionListener.java @@ -0,0 +1,100 @@ +package de.simolzimol.mclogger.paper.listeners; + +import de.simolzimol.mclogger.paper.PaperLoggerPlugin; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Handles login, logout and basic player session data. + * + * @author SimolZimol + */ +public class PlayerSessionListener implements Listener { + + private final PaperLoggerPlugin plugin; + /** Stores login timestamp per UUID for session duration calculation. */ + private final Map loginTimes = new ConcurrentHashMap<>(); + + public PlayerSessionListener(PaperLoggerPlugin plugin) { + this.plugin = plugin; + } + + /** + * Login event: player has authenticated and is connecting. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onLogin(PlayerLoginEvent event) { + Player p = event.getPlayer(); + String ip = event.getAddress() != null ? event.getAddress().getHostAddress() : "unknown"; + + plugin.getDb().upsertPlayer( + p.getUniqueId().toString(), + p.getName(), + p.getDisplayName().length() > 64 ? p.getDisplayName().substring(0, 64) : p.getDisplayName(), + ip, + p.locale().toLanguageTag(), + p.isOp() + ); + } + + /** + * Join event: player appears in-game. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onJoin(PlayerJoinEvent event) { + Player p = event.getPlayer(); + loginTimes.put(p.getUniqueId(), System.currentTimeMillis()); + + String ip = p.getAddress() != null + ? p.getAddress().getAddress().getHostAddress() : "unknown"; + + // Session is opened by the Velocity proxy on PostLoginEvent + + Map details = new HashMap<>(); + details.put("join_message", event.joinMessage() != null ? event.joinMessage().toString() : ""); + details.put("first_join", !p.hasPlayedBefore()); + details.put("gamemode", p.getGameMode().name()); + plugin.getDb().insertServerEvent("player_join", + p.getName() + " joined the server", details); + } + + /** + * Quit event: player leaves the server. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + Player p = event.getPlayer(); + UUID uuid = p.getUniqueId(); + Long storedLogin = loginTimes.remove(uuid); + long durationSec = storedLogin != null ? (System.currentTimeMillis() - storedLogin) / 1000 : 0; + + // Session is closed by the Velocity proxy on DisconnectEvent + + Map details = new HashMap<>(); + details.put("reason", event.getReason().name()); + details.put("playtime_sec", durationSec); + plugin.getDb().insertServerEvent("player_quit", + p.getName() + " left the server", details); + } + + /** + * Kick event: player was kicked. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onKick(PlayerKickEvent event) { + Player p = event.getPlayer(); + Map details = new HashMap<>(); + details.put("reason", event.getReason()); + details.put("cause", event.getCause().name()); + plugin.getDb().insertServerEvent("player_kick", + p.getName() + " was kicked: " + event.getReason(), details); + } +} diff --git a/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/WorldListener.java b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/WorldListener.java new file mode 100644 index 0000000..04c4d1b --- /dev/null +++ b/paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/WorldListener.java @@ -0,0 +1,98 @@ +package de.simolzimol.mclogger.paper.listeners; + +import de.simolzimol.mclogger.paper.PaperLoggerPlugin; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.weather.ThunderChangeEvent; +import org.bukkit.event.weather.WeatherChangeEvent; +import org.bukkit.event.world.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * Logs world events: weather, thunder, portal creation, + * tree/mushroom growth, chunk load, etc. + * + * @author SimolZimol + */ +public class WorldListener implements Listener { + + private final PaperLoggerPlugin plugin; + + public WorldListener(PaperLoggerPlugin plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onWeather(WeatherChangeEvent event) { + Map details = new HashMap<>(); + details.put("to_storm", event.toWeatherState()); + plugin.getDb().insertWorldEvent( + "weather_change", + event.getWorld().getName(), + null, null, null, + details + ); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onThunder(ThunderChangeEvent event) { + Map details = new HashMap<>(); + details.put("to_thunder", event.toThunderState()); + plugin.getDb().insertWorldEvent( + "thunder_change", + event.getWorld().getName(), + null, null, null, + details + ); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPortal(PortalCreateEvent event) { + Map details = new HashMap<>(); + details.put("reason", event.getReason().name()); + details.put("blocks", event.getBlocks().size()); + if (event.getEntity() != null) { + details.put("entity", event.getEntity().getType().name()); + } + plugin.getDb().insertWorldEvent( + "portal_create", + event.getWorld().getName(), + event.getBlocks().isEmpty() ? null : (double) event.getBlocks().get(0).getLocation().getBlockX(), + event.getBlocks().isEmpty() ? null : (double) event.getBlocks().get(0).getLocation().getBlockY(), + event.getBlocks().isEmpty() ? null : (double) event.getBlocks().get(0).getLocation().getBlockZ(), + details + ); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onStructureGrow(StructureGrowEvent event) { + Map details = new HashMap<>(); + details.put("species", event.getSpecies().name()); + details.put("natural", event.isFromBonemeal()); + details.put("player", event.getPlayer() != null ? event.getPlayer().getName() : null); + plugin.getDb().insertWorldEvent( + "structure_grow", + event.getWorld().getName(), + (double) event.getLocation().getBlockX(), + (double) event.getLocation().getBlockY(), + (double) event.getLocation().getBlockZ(), + details + ); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onWorldLoad(WorldLoadEvent event) { + Map details = new HashMap<>(); + details.put("environment", event.getWorld().getEnvironment().name()); + details.put("seed", event.getWorld().getSeed()); + plugin.getDb().insertWorldEvent("world_load", event.getWorld().getName(), null, null, null, details); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onWorldUnload(WorldUnloadEvent event) { + plugin.getDb().insertWorldEvent("world_unload", event.getWorld().getName(), null, null, null, null); + } +} diff --git a/paper-plugin/src/main/resources/config.yml b/paper-plugin/src/main/resources/config.yml new file mode 100644 index 0000000..0162fa7 --- /dev/null +++ b/paper-plugin/src/main/resources/config.yml @@ -0,0 +1,28 @@ +# ============================================================ +# MCLogger – Paper Configuration +# Author: SimolZimol +# ============================================================ + +server: + # Unique name of this server (stored in the DB) + name: "survival-01" + +database: + host: "localhost" + port: 3306 + database: "mclogger" + username: "mclogger" + password: "change_me_please" + ssl: false + # Number of concurrent database connections + pool-size: 10 + +logging: + # Log block events (can be disabled under high traffic) + blocks: true + # Log entity spawns (WARNING: can produce a huge number of entries!) + entity-spawns: false + # Log inventory clicks (except crafting, enchanting) + inventory-clicks: true + # Only log destructive events (break/explode) = lower volume + blocks-break-only: false diff --git a/paper-plugin/src/main/resources/plugin.yml b/paper-plugin/src/main/resources/plugin.yml new file mode 100644 index 0000000..6f79cb4 --- /dev/null +++ b/paper-plugin/src/main/resources/plugin.yml @@ -0,0 +1,59 @@ +name: MCLogger +version: '1.0.0' +main: de.simolzimol.mclogger.paper.PaperLoggerPlugin +api-version: '1.20' +description: Minecraft event logger +author: SimolZimol + +softdepend: + - LuckPerms + +commands: + mclogger: + description: MCLogger admin command + usage: /mclogger + permission: mclogger.use + aliases: + - mcl + - mlog + +permissions: + mclogger.use: + description: Access to MCLogger commands + default: op + children: + mclogger.admin: true + mclogger.view.chat: true + mclogger.view.commands: true + mclogger.view.deaths: true + mclogger.view.sessions: true + mclogger.view.blocks: true + mclogger.view.perms: true + + mclogger.admin: + description: Full access to MCLogger (reload, status) + default: op + + mclogger.view.chat: + description: View chat logs in-game + default: op + + mclogger.view.commands: + description: View command logs in-game + default: op + + mclogger.view.deaths: + description: View death logs in-game + default: op + + mclogger.view.sessions: + description: View session logs in-game + default: op + + mclogger.view.blocks: + description: View block logs in-game + default: op + + mclogger.view.perms: + description: View LuckPerms permission changes in-game + default: op diff --git a/paper-plugin/target/classes/config.yml b/paper-plugin/target/classes/config.yml new file mode 100644 index 0000000..0162fa7 --- /dev/null +++ b/paper-plugin/target/classes/config.yml @@ -0,0 +1,28 @@ +# ============================================================ +# MCLogger – Paper Configuration +# Author: SimolZimol +# ============================================================ + +server: + # Unique name of this server (stored in the DB) + name: "survival-01" + +database: + host: "localhost" + port: 3306 + database: "mclogger" + username: "mclogger" + password: "change_me_please" + ssl: false + # Number of concurrent database connections + pool-size: 10 + +logging: + # Log block events (can be disabled under high traffic) + blocks: true + # Log entity spawns (WARNING: can produce a huge number of entries!) + entity-spawns: false + # Log inventory clicks (except crafting, enchanting) + inventory-clicks: true + # Only log destructive events (break/explode) = lower volume + blocks-break-only: false diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/PaperLoggerPlugin.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/PaperLoggerPlugin.class new file mode 100644 index 0000000..b0b432d Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/PaperLoggerPlugin.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/commands/MCLoggerCommand$RsConsumer.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/commands/MCLoggerCommand$RsConsumer.class new file mode 100644 index 0000000..51ef32e Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/commands/MCLoggerCommand$RsConsumer.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/commands/MCLoggerCommand.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/commands/MCLoggerCommand.class new file mode 100644 index 0000000..76d4855 Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/commands/MCLoggerCommand.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/database/DatabaseManager$ThrowingRunnable.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/database/DatabaseManager$ThrowingRunnable.class new file mode 100644 index 0000000..3466ed8 Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/database/DatabaseManager$ThrowingRunnable.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/database/DatabaseManager.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/database/DatabaseManager.class new file mode 100644 index 0000000..d12bf92 Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/database/DatabaseManager.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/BlockListener.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/BlockListener.class new file mode 100644 index 0000000..44b28ff Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/BlockListener.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/EntityListener.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/EntityListener.class new file mode 100644 index 0000000..c57f28c Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/EntityListener.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/InventoryListener.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/InventoryListener.class new file mode 100644 index 0000000..46d535f Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/InventoryListener.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/LuckPermsListener.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/LuckPermsListener.class new file mode 100644 index 0000000..3c45df6 Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/LuckPermsListener.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerChatCommandListener.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerChatCommandListener.class new file mode 100644 index 0000000..e43476e Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerChatCommandListener.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerDeathListener.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerDeathListener.class new file mode 100644 index 0000000..f44b639 Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerDeathListener.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerMiscListener.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerMiscListener.class new file mode 100644 index 0000000..fbdba30 Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerMiscListener.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerSessionListener.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerSessionListener.class new file mode 100644 index 0000000..e18deb8 Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerSessionListener.class differ diff --git a/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/WorldListener.class b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/WorldListener.class new file mode 100644 index 0000000..c69e54b Binary files /dev/null and b/paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/WorldListener.class differ diff --git a/paper-plugin/target/classes/plugin.yml b/paper-plugin/target/classes/plugin.yml new file mode 100644 index 0000000..6f79cb4 --- /dev/null +++ b/paper-plugin/target/classes/plugin.yml @@ -0,0 +1,59 @@ +name: MCLogger +version: '1.0.0' +main: de.simolzimol.mclogger.paper.PaperLoggerPlugin +api-version: '1.20' +description: Minecraft event logger +author: SimolZimol + +softdepend: + - LuckPerms + +commands: + mclogger: + description: MCLogger admin command + usage: /mclogger + permission: mclogger.use + aliases: + - mcl + - mlog + +permissions: + mclogger.use: + description: Access to MCLogger commands + default: op + children: + mclogger.admin: true + mclogger.view.chat: true + mclogger.view.commands: true + mclogger.view.deaths: true + mclogger.view.sessions: true + mclogger.view.blocks: true + mclogger.view.perms: true + + mclogger.admin: + description: Full access to MCLogger (reload, status) + default: op + + mclogger.view.chat: + description: View chat logs in-game + default: op + + mclogger.view.commands: + description: View command logs in-game + default: op + + mclogger.view.deaths: + description: View death logs in-game + default: op + + mclogger.view.sessions: + description: View session logs in-game + default: op + + mclogger.view.blocks: + description: View block logs in-game + default: op + + mclogger.view.perms: + description: View LuckPerms permission changes in-game + default: op diff --git a/paper-plugin/target/maven-archiver/pom.properties b/paper-plugin/target/maven-archiver/pom.properties new file mode 100644 index 0000000..51167a5 --- /dev/null +++ b/paper-plugin/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=mclogger-paper +groupId=de.simolzimol +version=1.0.0 diff --git a/paper-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/paper-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..1242f18 --- /dev/null +++ b/paper-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,16 @@ +de\simolzimol\mclogger\paper\commands\MCLoggerCommand.class +de\simolzimol\mclogger\paper\database\DatabaseManager$ThrowingRunnable.class +de\simolzimol\mclogger\paper\PaperLoggerPlugin.class +de\simolzimol\mclogger\paper\listeners\InventoryListener.class +de\simolzimol\mclogger\paper\database\DatabaseManager.class +de\simolzimol\mclogger\paper\listeners\LuckPermsListener$1.class +de\simolzimol\mclogger\paper\listeners\EntityListener.class +de\simolzimol\mclogger\paper\listeners\PlayerMiscListener.class +de\simolzimol\mclogger\paper\listeners\PlayerChatCommandListener.class +de\simolzimol\mclogger\paper\listeners\BlockListener.class +de\simolzimol\mclogger\paper\listeners\PlayerDeathListener.class +de\simolzimol\mclogger\paper\listeners\LuckPermsListener.class +de\simolzimol\mclogger\paper\listeners\WorldListener.class +de\simolzimol\mclogger\paper\listeners\PlayerSessionListener.class +de\simolzimol\mclogger\paper\commands\MCLoggerCommand$RsConsumer.class +de\simolzimol\mclogger\paper\listeners\InventoryListener$1.class diff --git a/paper-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/paper-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..4f0a330 --- /dev/null +++ b/paper-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,12 @@ +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\commands\MCLoggerCommand.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\database\DatabaseManager.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\BlockListener.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\EntityListener.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\InventoryListener.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\LuckPermsListener.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerChatCommandListener.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerDeathListener.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerMiscListener.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerSessionListener.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\WorldListener.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\PaperLoggerPlugin.java diff --git a/paper-plugin/target/mclogger-paper-1.0.0.jar b/paper-plugin/target/mclogger-paper-1.0.0.jar new file mode 100644 index 0000000..67b1afc Binary files /dev/null and b/paper-plugin/target/mclogger-paper-1.0.0.jar differ diff --git a/paper-plugin/target/original-mclogger-paper-1.0.0.jar b/paper-plugin/target/original-mclogger-paper-1.0.0.jar new file mode 100644 index 0000000..31841ea Binary files /dev/null and b/paper-plugin/target/original-mclogger-paper-1.0.0.jar differ diff --git a/velocity-plugin/pom.xml b/velocity-plugin/pom.xml new file mode 100644 index 0000000..f51b00a --- /dev/null +++ b/velocity-plugin/pom.xml @@ -0,0 +1,117 @@ + + + 4.0.0 + + de.simolzimol + mclogger-velocity + 1.0.0 + jar + + MCLogger-Velocity + Comprehensive Minecraft proxy event logger for Velocity with MariaDB storage + + + 17 + 17 + 17 + UTF-8 + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + + + + + com.velocitypowered + velocity-api + 3.3.0-SNAPSHOT + provided + + + + + com.zaxxer + HikariCP + 5.1.0 + + + + + org.mariadb.jdbc + mariadb-java-client + 3.4.1 + + + + + com.google.code.gson + gson + 2.11.0 + provided + + + + + ${project.artifactId}-${project.version} + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.2 + + + package + shade + + false + + + com.zaxxer.hikari + de.simolzimol.mclogger.velocity.lib.hikari + + + org.mariadb + de.simolzimol.mclogger.velocity.lib.mariadb + + + + + *:* + + META-INF/LICENSE* + META-INF/NOTICE* + META-INF/DEPENDENCIES + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + 17 + + + com.velocitypowered + velocity-api + 3.3.0-SNAPSHOT + + + + + + + diff --git a/velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/VelocityLoggerPlugin.java b/velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/VelocityLoggerPlugin.java new file mode 100644 index 0000000..1e85ec4 --- /dev/null +++ b/velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/VelocityLoggerPlugin.java @@ -0,0 +1,121 @@ +package de.simolzimol.mclogger.velocity; + +import com.google.inject.Inject; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; +import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; +import de.simolzimol.mclogger.velocity.database.VelocityDatabaseManager; +import de.simolzimol.mclogger.velocity.listeners.VelocityEventListener; +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** + * MCLogger – Velocity Proxy Plugin entry point + * + * @author SimolZimol + * @version 1.0.0 + */ +@Plugin( + id = "mclogger-velocity", + name = "MCLogger-Velocity", + version = "1.0.0", + description = "Comprehensive proxy event logger with MariaDB storage", + authors = {"SimolZimol"}, + url = "https://github.com/SimolZimol/MCLogger" +) +public class VelocityLoggerPlugin { + + private final ProxyServer server; + private final Logger logger; + private final Path dataDirectory; + + private VelocityDatabaseManager db; + + @Inject + public VelocityLoggerPlugin(ProxyServer server, Logger logger, + @DataDirectory Path dataDirectory) { + this.server = server; + this.logger = logger; + this.dataDirectory = dataDirectory; + } + + @Subscribe + public void onInitialize(ProxyInitializeEvent event) { + // Load configuration + ConfigurationNode cfg = loadConfig(); + if (cfg == null) { + logger.error("[MCLogger] Failed to load configuration!"); + return; + } + + // Database connection + db = new VelocityDatabaseManager(this); + if (!db.connect(cfg)) { + logger.error("[MCLogger] No database connection – plugin inactive."); + return; + } + + // Register plugin messaging channel incoming from Paper backends + server.getChannelRegistrar().register(MinecraftChannelIdentifier.from("mclogger:logged")); + + // Register listeners + server.getEventManager().register(this, new VelocityEventListener(this)); + + // Log proxy start + Map details = new HashMap<>(); + details.put("velocity_version", server.getVersion().getVersion()); + details.put("max_players", server.getConfiguration().getShowMaxPlayers()); + db.insertProxyEvent("proxy_start", null, null, null, null, null, details); + + logger.info("[MCLogger] Velocity plugin started! Proxy: " + db.getProxyName()); + } + + @Subscribe + public void onShutdown(ProxyShutdownEvent event) { + if (db != null && db.isConnected()) { + Map details = new HashMap<>(); + details.put("online_players", server.getPlayerCount()); + db.insertProxyEvent("proxy_stop", null, null, null, null, null, details); + try { Thread.sleep(300); } catch (InterruptedException ignored) {} + db.disconnect(); + } + logger.info("[MCLogger] Velocity plugin shut down."); + } + + private ConfigurationNode loadConfig() { + try { + if (!Files.exists(dataDirectory)) { + Files.createDirectories(dataDirectory); + } + Path configFile = dataDirectory.resolve("config.yml"); + if (!Files.exists(configFile)) { + try (InputStream in = getClass().getResourceAsStream("/velocity-config.yml")) { + if (in != null) Files.copy(in, configFile); + } + } + YamlConfigurationLoader loader = YamlConfigurationLoader.builder() + .path(configFile) + .build(); + return loader.load(); + } catch (IOException e) { + logger.error("[MCLogger] Error loading configuration: " + e.getMessage()); + return null; + } + } + + public ProxyServer getServer() { return server; } + public Logger getLogger() { return logger; } + public VelocityDatabaseManager getDb() { return db; } +} diff --git a/velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager.java b/velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager.java new file mode 100644 index 0000000..8af7229 --- /dev/null +++ b/velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager.java @@ -0,0 +1,262 @@ +package de.simolzimol.mclogger.velocity.database; + +import com.google.gson.Gson; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import de.simolzimol.mclogger.velocity.VelocityLoggerPlugin; +import org.spongepowered.configurate.ConfigurationNode; + +import java.sql.*; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; + +/** + * MariaDB manager for the Velocity plugin. + * Writes proxy events asynchronously to the database. + * + * @author SimolZimol + */ +public class VelocityDatabaseManager { + + private final VelocityLoggerPlugin plugin; + private HikariDataSource dataSource; + private String proxyName; + private static final Gson GSON = new Gson(); + + public VelocityDatabaseManager(VelocityLoggerPlugin plugin) { + this.plugin = plugin; + } + + // -------------------------------------------------------- + // Connection + // -------------------------------------------------------- + + public boolean connect(ConfigurationNode cfg) { + proxyName = cfg.node("proxy", "name").getString("proxy-01"); + + HikariConfig hk = new HikariConfig(); + hk.setDriverClassName("de.simolzimol.mclogger.velocity.lib.mariadb.jdbc.Driver"); + hk.setJdbcUrl(String.format("jdbc:mariadb://%s:%d/%s?useSSL=%b&autoReconnect=true&characterEncoding=UTF-8", + cfg.node("database", "host").getString("localhost"), + cfg.node("database", "port").getInt(3306), + cfg.node("database", "database").getString("mclogger"), + cfg.node("database", "ssl").getBoolean(false))); + hk.setUsername(cfg.node("database", "username").getString("root")); + hk.setPassword(cfg.node("database", "password").getString("")); + hk.setMaximumPoolSize(cfg.node("database", "pool-size").getInt(5)); + hk.setMinimumIdle(1); + hk.setConnectionTimeout(30_000); + hk.setIdleTimeout(600_000); + hk.setPoolName("MCLogger-Velocity"); + + try { + dataSource = new HikariDataSource(hk); + registerProxy(cfg); + plugin.getLogger().info("MCLogger-Velocity: Database connected - Proxy: " + proxyName); + return true; + } catch (Exception e) { + plugin.getLogger().error("MCLogger-Velocity: Database connection failed!", e); + return false; + } + } + + public void disconnect() { + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + } + } + + private void registerProxy(ConfigurationNode cfg) throws SQLException { + try (Connection con = dataSource.getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO servers (server_name, server_type) VALUES (?,?) " + + "ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP(3)")) { + ps.setString(1, proxyName); + ps.setString(2, "velocity"); + ps.executeUpdate(); + } + } + + Connection getConnection() throws SQLException { + return dataSource.getConnection(); + } + + // -------------------------------------------------------- + // Async helper + // -------------------------------------------------------- + + private void asyncExec(ThrowingRunnable r) { + CompletableFuture.runAsync(() -> { + try { + r.run(); + } catch (Exception e) { + plugin.getLogger().warn("MCLogger-Velocity: DB error: " + e.getMessage()); + } + }); + } + + @FunctionalInterface + interface ThrowingRunnable { + void run() throws Exception; + } + + // -------------------------------------------------------- + // Players + // -------------------------------------------------------- + + public void upsertPlayer(String uuid, String username, String ip) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO players (uuid, username, ip_address) VALUES (?,?,?) " + + "ON DUPLICATE KEY UPDATE username=VALUES(username), ip_address=VALUES(ip_address), last_seen=CURRENT_TIMESTAMP(3)")) { + ps.setString(1, uuid); + ps.setString(2, username); + ps.setString(3, ip); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Proxy Events (central table) + // -------------------------------------------------------- + + public void insertProxyEvent(String type, String playerUuid, String playerName, + String fromServer, String toServer, + String ip, Map details) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO proxy_events (event_type, player_uuid, player_name, proxy_name, from_server, to_server, ip_address, details) " + + "VALUES (?,?,?,?,?,?,?,?)")) { + ps.setString(1, type); + ps.setString(2, playerUuid); + ps.setString(3, playerName); + ps.setString(4, proxyName); + ps.setString(5, fromServer); + ps.setString(6, toServer); + ps.setString(7, ip); + ps.setString(8, details != null ? GSON.toJson(details) : null); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Chat & Commands (shared tables) + // -------------------------------------------------------- + + public void insertChat(String uuid, String name, String message) { + insertChatWithServer(uuid, name, proxyName, message); + } + + public void insertChatWithServer(String uuid, String name, String serverName, String message) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO player_chat (player_uuid, player_name, server_name, message, channel) VALUES (?,?,?,?,?)")) { + ps.setString(1, uuid); + ps.setString(2, name); + ps.setString(3, serverName); + ps.setString(4, message); + ps.setString(5, "global"); + ps.executeUpdate(); + } + }); + } + + public void insertCommand(String uuid, String name, String command) { + insertCommandWithServer(uuid, name, proxyName, command); + } + + public void insertCommandWithServer(String uuid, String name, String serverName, String command) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO player_commands (player_uuid, player_name, server_name, command) VALUES (?,?,?,?)")) { + ps.setString(1, uuid); + ps.setString(2, name); + ps.setString(3, serverName); + ps.setString(4, command); + ps.executeUpdate(); + } + }); + } + + // -------------------------------------------------------- + // Sessions + // -------------------------------------------------------- + + /** Opens a new session row. server_name is updated on disconnect to the last known backend. */ + public void insertSessionLogin(String uuid, String name, String ip, String clientBrand) { + asyncExec(() -> { + String country = lookupCountry(ip); + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO player_sessions (player_uuid, player_name, server_name, ip_address, country, client_version) VALUES (?,?,?,?,?,?)")) { + ps.setString(1, uuid); + ps.setString(2, name); + ps.setString(3, proxyName); + ps.setString(4, ip); + ps.setString(5, country); + ps.setString(6, clientBrand); + ps.executeUpdate(); + } + }); + } + + /** Closes the open session, sets the actual backend server name and duration. */ + public void closeSession(String uuid, String lastServer, long durationSec) { + asyncExec(() -> { + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "UPDATE player_sessions SET logout_time=CURRENT_TIMESTAMP(3), duration_sec=?, server_name=? " + + "WHERE player_uuid=? AND logout_time IS NULL ORDER BY login_time DESC LIMIT 1")) { + ps.setLong(1, durationSec); + ps.setString(2, lastServer); + ps.setString(3, uuid); + ps.executeUpdate(); + } + try (Connection con = getConnection(); + PreparedStatement ps = con.prepareStatement( + "UPDATE players SET total_playtime_sec = total_playtime_sec + ? WHERE uuid = ?")) { + ps.setLong(1, durationSec); + ps.setString(2, uuid); + ps.executeUpdate(); + } + }); + } + + private String lookupCountry(String ip) { + if (ip == null || ip.equals("unknown") || ip.equals("::1") + || ip.startsWith("127.") || ip.startsWith("10.") + || ip.startsWith("192.168.") || ip.startsWith("172.")) { + return "Local"; + } + try { + java.net.URL url = new java.net.URL("http://ip-api.com/json/" + ip + "?fields=country"); + java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + conn.setRequestMethod("GET"); + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(conn.getInputStream()))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) sb.append(line); + com.google.gson.JsonObject obj = new com.google.gson.Gson().fromJson(sb.toString(), com.google.gson.JsonObject.class); + if (obj != null && obj.has("country")) return obj.get("country").getAsString(); + } + } catch (Exception ignored) {} + return "Unknown"; + } + + // -------------------------------------------------------- + // Getter + // -------------------------------------------------------- + + public String getProxyName() { return proxyName; } + public boolean isConnected() { return dataSource != null && !dataSource.isClosed(); } +} diff --git a/velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/listeners/VelocityEventListener.java b/velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/listeners/VelocityEventListener.java new file mode 100644 index 0000000..3dbb816 --- /dev/null +++ b/velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/listeners/VelocityEventListener.java @@ -0,0 +1,232 @@ +package de.simolzimol.mclogger.velocity.listeners; + +import com.velocitypowered.api.event.PostOrder; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.command.CommandExecuteEvent; +import com.velocitypowered.api.event.connection.DisconnectEvent; +import com.velocitypowered.api.event.connection.PluginMessageEvent; +import com.velocitypowered.api.event.connection.PostLoginEvent; +import com.velocitypowered.api.event.player.PlayerChatEvent; +import com.velocitypowered.api.event.player.ServerConnectedEvent; +import com.velocitypowered.api.event.player.ServerPreConnectEvent; +import com.velocitypowered.api.proxy.Player; +import de.simolzimol.mclogger.velocity.VelocityLoggerPlugin; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * All Velocity event listeners: + * - Login / Disconnect + * - Server switch + * - Chat + * - Commands + * + * @author SimolZimol + */ +public class VelocityEventListener { + + private final VelocityLoggerPlugin plugin; + /** Login timestamps for session duration calculation */ + private final Map loginTimes = new ConcurrentHashMap<>(); + /** Commands already logged by a Paper backend – Velocity skips these */ + private final Set paperLoggedCommands = ConcurrentHashMap.newKeySet(); + + public VelocityEventListener(VelocityLoggerPlugin plugin) { + this.plugin = plugin; + } + + // -------------------------------------------------------- + // Login + // -------------------------------------------------------- + + @Subscribe(order = PostOrder.LAST) + public void onLogin(PostLoginEvent event) { + Player p = event.getPlayer(); + loginTimes.put(p.getUniqueId(), System.currentTimeMillis()); + + String ip = p.getRemoteAddress() != null + ? p.getRemoteAddress().getAddress().getHostAddress() : "unknown"; + + // Upsert player in DB + plugin.getDb().upsertPlayer(p.getUniqueId().toString(), p.getUsername(), ip); + + // Open session row (server_name is filled in on disconnect with last known backend) + String clientBrand = p.getClientBrand() != null ? p.getClientBrand() : "unknown"; + plugin.getDb().insertSessionLogin(p.getUniqueId().toString(), p.getUsername(), ip, clientBrand); + + Map details = new HashMap<>(); + details.put("ip", ip); + details.put("client_brand", p.getClientBrand() != null ? p.getClientBrand() : "unknown"); + details.put("protocol", p.getProtocolVersion().getProtocol()); + details.put("ping", p.getPing()); + details.put("online_mode", !p.isOnlineMode() ? "offline/bedrock" : "java"); + + plugin.getDb().insertProxyEvent( + "login", + p.getUniqueId().toString(), + p.getUsername(), + null, null, ip, details + ); + + plugin.getLogger().info("[MCLogger] LOGIN: " + p.getUsername() + " (" + ip + ")"); + } + + // -------------------------------------------------------- + // Disconnect + // -------------------------------------------------------- + + @Subscribe(order = PostOrder.LAST) + public void onDisconnect(DisconnectEvent event) { + Player p = event.getPlayer(); + UUID uuid = p.getUniqueId(); + + Long loginTimeMs = loginTimes.remove(uuid); + long durationSec = loginTimeMs != null ? (System.currentTimeMillis() - loginTimeMs) / 1000 : 0; + + String lastServer = p.getCurrentServer() + .map(s -> s.getServerInfo().getName()) + .orElse(plugin.getDb().getProxyName()); + + // Close the session opened in onLogin + plugin.getDb().closeSession(uuid.toString(), lastServer, durationSec); + + Map details = new HashMap<>(); + details.put("reason", event.getLoginStatus().name()); + details.put("session_duration_sec", durationSec); + details.put("current_server", lastServer); + + plugin.getDb().insertProxyEvent( + "disconnect", + uuid.toString(), + p.getUsername(), + p.getCurrentServer().map(s -> s.getServerInfo().getName()).orElse(null), + null, null, details + ); + + plugin.getLogger().info("[MCLogger] DISCONNECT: " + p.getUsername() + + " (duration: " + durationSec + "s, reason: " + event.getLoginStatus().name() + ")"); + } + + // -------------------------------------------------------- + // Server switch (after switching) + // -------------------------------------------------------- + + @Subscribe(order = PostOrder.LAST) + public void onServerConnected(ServerConnectedEvent event) { + Player p = event.getPlayer(); + String from = event.getPreviousServer().map(s -> s.getServerInfo().getName()).orElse(null); + String to = event.getServer().getServerInfo().getName(); + + Map details = new HashMap<>(); + details.put("ping", p.getPing()); + + plugin.getDb().insertProxyEvent( + "server_switch", + p.getUniqueId().toString(), + p.getUsername(), + from, to, null, details + ); + + plugin.getLogger().info("[MCLogger] SERVER-SWITCH: " + p.getUsername() + + " | " + (from != null ? from : "joining") + " → " + to); + } + + // -------------------------------------------------------- + // Server connect attempt (before switching) + // -------------------------------------------------------- + + @Subscribe(order = PostOrder.LAST) + public void onServerPreConnect(ServerPreConnectEvent event) { + if (event.getResult().getServer().isEmpty()) return; + Player p = event.getPlayer(); + + Map details = new HashMap<>(); + details.put("target_server", event.getResult().getServer().get().getServerInfo().getName()); + + plugin.getDb().insertProxyEvent( + "server_switch", + p.getUniqueId().toString(), + p.getUsername(), + p.getCurrentServer().map(s -> s.getServerInfo().getName()).orElse(null), + event.getResult().getServer().get().getServerInfo().getName(), + null, details + ); + } + + // -------------------------------------------------------- + // Commands – only proxy-native commands (e.g. /server, /glist) + // Commands – Paper logs with position and notifies proxy; Velocity is fallback. + // -------------------------------------------------------- + + /** + * Receives plugin messages from Paper backends. + * When Paper has logged a command, it sends the key here so Velocity skips it. + */ + @Subscribe + public void onPluginMessage(PluginMessageEvent event) { + if (!event.getIdentifier().getId().equals("mclogger:logged")) return; + // Prevent Velocity from forwarding this internal message to the client + event.setResult(PluginMessageEvent.ForwardResult.handled()); + String key = new String(event.getData(), StandardCharsets.UTF_8); + paperLoggedCommands.add(key); + } + + /** + * Schedules command logging with a 300 ms delay. + * If a Paper backend signals it already logged the command, the task is cancelled. + * On servers without the Paper plugin, Velocity logs it as fallback. + */ + @Subscribe(order = PostOrder.LAST) + public void onCommand(CommandExecuteEvent event) { + if (!(event.getCommandSource() instanceof Player)) return; + Player p = (Player) event.getCommandSource(); + + String rawCommand = event.getCommand(); + // Key format matches what Paper sends: uuid|/command args + String key = p.getUniqueId() + "|/" + rawCommand; + String serverName = p.getCurrentServer() + .map(s -> s.getServerInfo().getName()) + .orElse(plugin.getDb().getProxyName()); + + plugin.getServer().getScheduler() + .buildTask(plugin, () -> { + // If Paper already sent us a confirmation, skip – avoid duplicate + if (paperLoggedCommands.remove(key)) return; + plugin.getDb().insertCommandWithServer( + p.getUniqueId().toString(), + p.getUsername(), + serverName, + "/" + rawCommand + ); + }) + .delay(Duration.ofMillis(300)) + .schedule(); + } + + // Note: Chat is logged at proxy level (see onChat below). + + // -------------------------------------------------------- + // Chat – logged at proxy level with correct backend server name + // -------------------------------------------------------- + + @Subscribe(order = PostOrder.LAST) + public void onChat(PlayerChatEvent event) { + if (!event.getResult().isAllowed()) return; + Player p = event.getPlayer(); + String serverName = p.getCurrentServer() + .map(s -> s.getServerInfo().getName()) + .orElse(plugin.getDb().getProxyName()); + plugin.getDb().insertChatWithServer( + p.getUniqueId().toString(), + p.getUsername(), + serverName, + event.getMessage() + ); + } +} diff --git a/velocity-plugin/src/main/resources/velocity-config.yml b/velocity-plugin/src/main/resources/velocity-config.yml new file mode 100644 index 0000000..47caf40 --- /dev/null +++ b/velocity-plugin/src/main/resources/velocity-config.yml @@ -0,0 +1,17 @@ +# ============================================================ +# MCLogger – Velocity Konfiguration +# Author: SimolZimol +# ============================================================ + +proxy: + # Eindeutiger Name dieses Proxies (wird in der DB gespeichert) + name: "proxy-01" + +database: + host: "localhost" + port: 3306 + database: "mclogger" + username: "mclogger" + password: "change_me_please" + ssl: false + pool-size: 5 diff --git a/velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/VelocityLoggerPlugin.class b/velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/VelocityLoggerPlugin.class new file mode 100644 index 0000000..213e739 Binary files /dev/null and b/velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/VelocityLoggerPlugin.class differ diff --git a/velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager$ThrowingRunnable.class b/velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager$ThrowingRunnable.class new file mode 100644 index 0000000..62e7420 Binary files /dev/null and b/velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager$ThrowingRunnable.class differ diff --git a/velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager.class b/velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager.class new file mode 100644 index 0000000..505bc1f Binary files /dev/null and b/velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager.class differ diff --git a/velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/listeners/VelocityEventListener.class b/velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/listeners/VelocityEventListener.class new file mode 100644 index 0000000..d70be93 Binary files /dev/null and b/velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/listeners/VelocityEventListener.class differ diff --git a/velocity-plugin/target/classes/velocity-config.yml b/velocity-plugin/target/classes/velocity-config.yml new file mode 100644 index 0000000..47caf40 --- /dev/null +++ b/velocity-plugin/target/classes/velocity-config.yml @@ -0,0 +1,17 @@ +# ============================================================ +# MCLogger – Velocity Konfiguration +# Author: SimolZimol +# ============================================================ + +proxy: + # Eindeutiger Name dieses Proxies (wird in der DB gespeichert) + name: "proxy-01" + +database: + host: "localhost" + port: 3306 + database: "mclogger" + username: "mclogger" + password: "change_me_please" + ssl: false + pool-size: 5 diff --git a/velocity-plugin/target/classes/velocity-plugin.json b/velocity-plugin/target/classes/velocity-plugin.json new file mode 100644 index 0000000..3612ea7 --- /dev/null +++ b/velocity-plugin/target/classes/velocity-plugin.json @@ -0,0 +1 @@ +{"id":"mclogger-velocity","name":"MCLogger-Velocity","version":"1.0.0","description":"Comprehensive proxy event logger with MariaDB storage","url":"https://github.com/SimolZimol/MCLogger","authors":["SimolZimol"],"dependencies":[],"main":"de.simolzimol.mclogger.velocity.VelocityLoggerPlugin"} \ No newline at end of file diff --git a/velocity-plugin/target/maven-archiver/pom.properties b/velocity-plugin/target/maven-archiver/pom.properties new file mode 100644 index 0000000..cae5500 --- /dev/null +++ b/velocity-plugin/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=mclogger-velocity +groupId=de.simolzimol +version=1.0.0 diff --git a/velocity-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/velocity-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..04a65d6 --- /dev/null +++ b/velocity-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,5 @@ +de\simolzimol\mclogger\velocity\database\VelocityDatabaseManager.class +velocity-plugin.json +de\simolzimol\mclogger\velocity\database\VelocityDatabaseManager$ThrowingRunnable.class +de\simolzimol\mclogger\velocity\listeners\VelocityEventListener.class +de\simolzimol\mclogger\velocity\VelocityLoggerPlugin.class diff --git a/velocity-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/velocity-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..5c5fcdf --- /dev/null +++ b/velocity-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,3 @@ +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\database\VelocityDatabaseManager.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\listeners\VelocityEventListener.java +C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\VelocityLoggerPlugin.java diff --git a/velocity-plugin/target/mclogger-velocity-1.0.0.jar b/velocity-plugin/target/mclogger-velocity-1.0.0.jar new file mode 100644 index 0000000..a99bde3 Binary files /dev/null and b/velocity-plugin/target/mclogger-velocity-1.0.0.jar differ diff --git a/velocity-plugin/target/original-mclogger-velocity-1.0.0.jar b/velocity-plugin/target/original-mclogger-velocity-1.0.0.jar new file mode 100644 index 0000000..dcd1ef6 Binary files /dev/null and b/velocity-plugin/target/original-mclogger-velocity-1.0.0.jar differ diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..ee42339 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,24 @@ +# MCLogger Web Panel +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +ENV PYTHONUNBUFFERED=1 \ + FLASK_APP=app.py + +# Non-root user +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "60", "app:app"] + diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..d7a5f9a --- /dev/null +++ b/web/app.py @@ -0,0 +1,76 @@ +""" +MCLogger – Flask Web-Panel +Multi-Tenant mit Gruppen, Rollen & verschlüsselten DB-Zugangsdaten. +Coolify-kompatibel: alle Einstellungen via ENV. +""" +from datetime import datetime +from flask import Flask, session +from config import Config +from panel_db import init_databases, get_user_groups + +from blueprints.auth import auth +from blueprints.site_admin import site_admin +from blueprints.group_admin import group_admin +from blueprints.panel import panel + + +def create_app() -> Flask: + app = Flask(__name__) + app.secret_key = Config.SECRET_KEY + + # Blueprints registrieren + app.register_blueprint(auth) + app.register_blueprint(site_admin) + app.register_blueprint(group_admin) + app.register_blueprint(panel) + + # Panel-Datenbank-Tabellen anlegen + try: + init_databases() + except Exception as e: + app.logger.warning(f"DB-Initialisierung fehlgeschlagen (noch nicht konfiguriert?): {e}") + + # ── Template-Filter ─────────────────────────────────────── + + @app.template_filter("fmt_duration") + def fmt_duration(seconds): + if seconds is None: + return "—" + seconds = int(seconds) + h = seconds // 3600 + m = (seconds % 3600) // 60 + s = seconds % 60 + if h: return f"{h}h {m}m" + elif m: return f"{m}m {s}s" + return f"{s}s" + + @app.template_filter("fmt_dt") + def fmt_dt(dt): + if dt is None: + return "—" + if isinstance(dt, str): + return dt + return dt.strftime("%d.%m.%Y %H:%M:%S") + + @app.context_processor + def inject_globals(): + uid = session.get("user_id") + try: + groups = get_user_groups(uid) if uid else [] + except Exception: + groups = [] + return { + "now": datetime.now(), + "app_version": "2.0.0", + "author": "SimolZimol", + "user_groups": groups, + } + + return app + + +app = create_app() + +if __name__ == "__main__": + app.run(host=Config.HOST, port=Config.PORT, debug=Config.DEBUG) + diff --git a/web/blueprints/__init__.py b/web/blueprints/__init__.py new file mode 100644 index 0000000..267e11e --- /dev/null +++ b/web/blueprints/__init__.py @@ -0,0 +1 @@ +# blueprints/__init__.py diff --git a/web/blueprints/auth.py b/web/blueprints/auth.py new file mode 100644 index 0000000..215e745 --- /dev/null +++ b/web/blueprints/auth.py @@ -0,0 +1,93 @@ +""" +MCLogger – Authentifizierung +Getrennte Login-Seiten für Site-Admins und normale Nutzer/Gruppen-Admins. +""" +import json +from flask import Blueprint, render_template, request, redirect, url_for, session, flash +from panel_db import check_login, get_user_groups + +auth = Blueprint("auth", __name__) + + +@auth.route("/login", methods=["GET", "POST"]) +def login(): + if session.get("user_id"): + return redirect(url_for("panel.dashboard")) + error = None + if request.method == "POST": + user = check_login(request.form.get("username", ""), request.form.get("password", "")) + if user and user["is_site_admin"]: + flash("Bitte nutze den Site-Admin-Login.", "warning") + return redirect(url_for("auth.admin_login")) + if user: + groups = get_user_groups(user["id"]) + if not groups: + error = "Du bist keiner Gruppe zugewiesen. Wende dich an einen Admin." + else: + _set_user_session(user, groups) + return redirect(url_for("panel.dashboard")) + else: + error = "Falscher Benutzername oder Passwort." + return render_template("auth/login.html", error=error) + + +@auth.route("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("is_site_admin"): + return redirect(url_for("site_admin.dashboard")) + error = None + if request.method == "POST": + user = check_login(request.form.get("username", ""), request.form.get("password", "")) + if user and user["is_site_admin"]: + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_site_admin"] = True + session["group_id"] = None + session["permissions"] = {} + return redirect(url_for("site_admin.dashboard")) + elif user: + error = "Keine Site-Admin-Berechtigung." + else: + error = "Falscher Benutzername oder Passwort." + return render_template("auth/admin_login.html", error=error) + + +@auth.route("/logout") +def logout(): + session.clear() + return redirect(url_for("auth.login")) + + +@auth.route("/switch-group/") +def switch_group(group_id): + if not session.get("user_id") or session.get("is_site_admin"): + return redirect(url_for("auth.login")) + user_id = session["user_id"] + groups = get_user_groups(user_id) + target = next((g for g in groups if g["id"] == group_id), None) + if not target: + flash("Gruppe nicht gefunden oder kein Zugriff.", "danger") + return redirect(url_for("panel.dashboard")) + _apply_group(target) + return redirect(url_for("panel.dashboard")) + + +def _set_user_session(user, groups): + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_site_admin"] = False + _apply_group(groups[0]) # Erste Gruppe als Standard + + +def _apply_group(group): + raw = group.get("permissions") + if isinstance(raw, str): + perms = json.loads(raw) + elif isinstance(raw, dict): + perms = raw + else: + perms = {} + session["group_id"] = group["id"] + session["group_name"] = group["name"] + session["role"] = group.get("role", "member") + session["permissions"] = perms diff --git a/web/blueprints/group_admin.py b/web/blueprints/group_admin.py new file mode 100644 index 0000000..247b44c --- /dev/null +++ b/web/blueprints/group_admin.py @@ -0,0 +1,164 @@ +""" +MCLogger – Gruppen-Admin-Bereich +Gruppen-Admins können ihre Mitglieder und MC-DB-Verbindung verwalten. +""" +import json +from functools import wraps +from flask import Blueprint, render_template, request, redirect, url_for, session, flash +import panel_db as db + +group_admin = Blueprint("group_admin", __name__, url_prefix="/group-admin") + +ALL_PERMISSIONS = [ + ("view_dashboard", "Dashboard"), + ("view_players", "Spieler"), + ("view_sessions", "Sessions"), + ("view_chat", "Chat"), + ("view_commands", "Commands"), + ("view_deaths", "Tode"), + ("view_blocks", "Block-Events"), + ("view_proxy", "Proxy-Events"), + ("view_server_events", "Server-Events"), + ("view_perms", "Berechtigungen"), +] + + +def group_admin_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("user_id"): + return redirect(url_for("auth.login")) + if session.get("is_site_admin"): + return redirect(url_for("site_admin.dashboard")) + if session.get("role") != "admin": + flash("Du hast keine Gruppen-Admin-Berechtigung.", "danger") + return redirect(url_for("panel.dashboard")) + return f(*args, **kwargs) + return decorated + + +@group_admin.route("/") +@group_admin_required +def dashboard(): + group_id = session["group_id"] + group = db.get_group_by_id(group_id) + members = db.get_group_members(group_id) + has_db = db.has_db_configured(group_id) + return render_template("group_admin/dashboard.html", + group=group, members=members, has_db=has_db) + + +# ────────────────────────────────────────────────────────────── +# Mitglieder +# ────────────────────────────────────────────────────────────── + +@group_admin.route("/members") +@group_admin_required +def members(): + group_id = session["group_id"] + group = db.get_group_by_id(group_id) + members = db.get_group_members(group_id) + all_users = db.list_all_users() + member_ids = {m["id"] for m in members} + non_members = [u for u in all_users if u["id"] not in member_ids and not u["is_site_admin"]] + return render_template("group_admin/members.html", + group=group, members=members, non_members=non_members, + all_permissions=ALL_PERMISSIONS) + + +@group_admin.route("/members/add", methods=["POST"]) +@group_admin_required +def member_add(): + group_id = session["group_id"] + user_id = request.form.get("user_id", type=int) + role = request.form.get("role", "member") + if user_id: + db.add_group_member(user_id, group_id, role) + flash("Mitglied hinzugefügt.", "success") + return redirect(url_for("group_admin.members")) + + +@group_admin.route("/members//edit", methods=["GET", "POST"]) +@group_admin_required +def member_edit(user_id): + group_id = session["group_id"] + group = db.get_group_by_id(group_id) + member = db.get_group_member(user_id, group_id) + user = db.get_user_by_id(user_id) + if not member or not user: + flash("Mitglied nicht gefunden.", "danger") + return redirect(url_for("group_admin.members")) + + raw_perms = member.get("permissions") + current_perms = json.loads(raw_perms) if isinstance(raw_perms, str) else (raw_perms or {}) + + if request.method == "POST": + role = request.form.get("role", "member") + new_perms = {key: (request.form.get(key) == "1") for key, _ in ALL_PERMISSIONS} + db.update_member(user_id, group_id, role, new_perms) + flash("Berechtigungen aktualisiert.", "success") + return redirect(url_for("group_admin.members")) + + return render_template("group_admin/member_edit.html", + group=group, user=user, member=member, + current_perms=current_perms, all_permissions=ALL_PERMISSIONS) + + +@group_admin.route("/members//remove", methods=["POST"]) +@group_admin_required +def member_remove(user_id): + if user_id == session["user_id"]: + flash("Du kannst dich nicht selbst entfernen.", "danger") + else: + db.remove_group_member(user_id, session["group_id"]) + flash("Mitglied entfernt.", "success") + return redirect(url_for("group_admin.members")) + + +# ────────────────────────────────────────────────────────────── +# Datenbank-Konfiguration +# ────────────────────────────────────────────────────────────── + +@group_admin.route("/database", methods=["GET", "POST"]) +@group_admin_required +def database(): + group_id = session["group_id"] + group = db.get_group_by_id(group_id) + has_db = db.has_db_configured(group_id) + error = None + + if request.method == "POST": + host = request.form.get("host", "").strip() + port = request.form.get("port", "3306").strip() + user = request.form.get("user", "").strip() + password = request.form.get("password", "") + database_name = request.form.get("database", "").strip() + + if not all([host, port, user, database_name]): + error = "Host, Port, Benutzer und Datenbankname sind Pflichtfelder." + else: + try: + # Verbindung testen + import pymysql + test = pymysql.connect( + host=host, port=int(port), user=user, + password=password, database=database_name, + connect_timeout=5 + ) + test.close() + db.set_group_db_creds(group_id, host, int(port), user, password, database_name) + flash("Datenbankverbindung gespeichert und getestet ✓", "success") + return redirect(url_for("group_admin.database")) + except Exception as e: + error = f"Verbindungstest fehlgeschlagen: {e}" + + return render_template("group_admin/database.html", + group=group, has_db=has_db, error=error) + + +@group_admin.route("/database/delete", methods=["POST"]) +@group_admin_required +def database_delete(): + db.delete_group_db_creds(session["group_id"]) + flash("Datenbankverbindung entfernt.", "success") + return redirect(url_for("group_admin.database")) diff --git a/web/blueprints/panel.py b/web/blueprints/panel.py new file mode 100644 index 0000000..88fa399 --- /dev/null +++ b/web/blueprints/panel.py @@ -0,0 +1,410 @@ +""" +MCLogger – Panel (MC-Daten) +Zeigt die Minecraft-Logdaten der Gruppe an. +Die Datenbankverbindung kommt aus den verschlüsselten Gruppen-Credentials. +""" +from functools import wraps +from datetime import datetime +from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify, abort +import pymysql +import pymysql.cursors +import panel_db as pdb + +panel = Blueprint("panel", __name__) + + +# ───────────────────────────────────────────────────────────── +# Hilfsfunktionen +# ───────────────────────────────────────────────────────────── + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("user_id"): + return redirect(url_for("auth.login")) + if session.get("is_site_admin") and not session.get("group_id"): + return redirect(url_for("site_admin.dashboard")) + if not session.get("group_id"): + return redirect(url_for("auth.login")) + return f(*args, **kwargs) + return decorated + + +def perm_required(perm): + def decorator(f): + @wraps(f) + def wrapped(*args, **kwargs): + if session.get("is_site_admin") or session.get("role") == "admin": + return f(*args, **kwargs) + perms = session.get("permissions", {}) + if not perms.get(perm, False): + flash("Du hast keine Berechtigung, diese Seite zu sehen.", "danger") + return redirect(url_for("panel.dashboard")) + return f(*args, **kwargs) + return wrapped + return decorator + + +def get_mc_db(): + """Liefert eine Datenbankverbindung zur MC-Datenbank der aktuellen Gruppe.""" + group_id = session.get("group_id") + if not group_id: + abort(403) + creds = pdb.get_group_db_creds(group_id) + if not creds: + abort(503) + return pymysql.connect( + host=creds["host"], + port=creds["port"], + user=creds["user"], + password=creds["password"], + database=creds["database"], + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + autocommit=True, + connect_timeout=10, + ) + + +def query(sql, args=None, fetchone=False): + conn = get_mc_db() + try: + with conn.cursor() as cur: + cur.execute(sql, args or ()) + return cur.fetchone() if fetchone else cur.fetchall() + finally: + conn.close() + + +def query_paged(sql, count_sql, args=None, page=1, per_page=50): + args = args or () + total_row = query(count_sql, args, fetchone=True) + total = list(total_row.values())[0] if total_row else 0 + pages = max(1, (total + per_page - 1) // per_page) + offset = (page - 1) * per_page + rows = query(sql + f" LIMIT {per_page} OFFSET {offset}", args) + return rows, total, pages + + +# ───────────────────────────────────────────────────────────── +# Fehler-Handler wenn DB nicht konfiguriert +# ───────────────────────────────────────────────────────────── + +@panel.errorhandler(503) +def no_db(e): + return render_template("panel/no_db.html"), 503 + + +# ───────────────────────────────────────────────────────────── +# Dashboard +# ───────────────────────────────────────────────────────────── + +@panel.route("/") +@login_required +@perm_required("view_dashboard") +def dashboard(): + group_id = session["group_id"] + if not pdb.has_db_configured(group_id): + return render_template("panel/no_db.html") + try: + stats = { + "players_total": query("SELECT COUNT(*) AS c FROM players", fetchone=True)["c"], + "sessions_today": query("SELECT COUNT(*) AS c FROM player_sessions WHERE login_time >= CURDATE()", fetchone=True)["c"], + "chat_today": query("SELECT COUNT(*) AS c FROM player_chat WHERE timestamp >= CURDATE()", fetchone=True)["c"], + "commands_today": query("SELECT COUNT(*) AS c FROM player_commands WHERE timestamp >= CURDATE()", fetchone=True)["c"], + "blocks_today": query("SELECT COUNT(*) AS c FROM block_events WHERE timestamp >= CURDATE()", fetchone=True)["c"], + "deaths_today": query("SELECT COUNT(*) AS c FROM player_deaths WHERE timestamp >= CURDATE()", fetchone=True)["c"], + "proxy_events_today": query("SELECT COUNT(*) AS c FROM proxy_events WHERE timestamp >= CURDATE()", fetchone=True)["c"], + } + online = query(""" + SELECT p.username, ps.server_name, ps.login_time, ps.country + FROM player_sessions ps + JOIN players p ON p.uuid = ps.player_uuid + WHERE ps.logout_time IS NULL + ORDER BY ps.login_time DESC + """) + top_players = query(""" + SELECT username, total_playtime_sec + FROM players ORDER BY total_playtime_sec DESC LIMIT 10 + """) + death_causes = query(""" + SELECT cause, COUNT(*) AS cnt FROM player_deaths + WHERE timestamp >= NOW() - INTERVAL 7 DAY + GROUP BY cause ORDER BY cnt DESC LIMIT 8 + """) + server_events = query(""" + SELECT timestamp, event_type, server_name, message + FROM server_events + WHERE timestamp >= NOW() - INTERVAL 24 HOUR + ORDER BY timestamp DESC LIMIT 20 + """) + except Exception as e: + flash(f"Datenbankfehler: {e}", "danger") + return render_template("panel/no_db.html") + + return render_template("panel/dashboard.html", + stats=stats, online=online, top_players=top_players, + death_causes=death_causes, server_events=server_events) + + +# ───────────────────────────────────────────────────────────── +# Spieler +# ───────────────────────────────────────────────────────────── + +@panel.route("/players") +@login_required +@perm_required("view_players") +def players(): + search = request.args.get("q", "") + page = max(1, request.args.get("page", 1, type=int)) + if search: + base = "FROM players WHERE username LIKE %s" + args = (f"%{search}%",) + else: + base = "FROM players WHERE 1" + args = () + rows, total, pages = query_paged( + f"SELECT * {base} ORDER BY last_seen DESC", + f"SELECT COUNT(*) AS c {base}", args, page) + return render_template("panel/players.html", + players=rows, total=total, pages=pages, page=page, search=search) + + +@panel.route("/players/") +@login_required +@perm_required("view_players") +def player_detail(uuid): + player = query("SELECT * FROM players WHERE uuid = %s", (uuid,), fetchone=True) + if not player: + flash("Spieler nicht gefunden.", "danger") + return redirect(url_for("panel.players")) + perms = session.get("permissions", {}) + is_admin = session.get("is_site_admin") or session.get("role") == "admin" + return render_template("panel/player_detail.html", + player=player, + sessions = query("SELECT * FROM player_sessions WHERE player_uuid=%s ORDER BY login_time DESC LIMIT 20", (uuid,)), + chat = query("SELECT * FROM player_chat WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 50", (uuid,)) if (is_admin or perms.get("view_chat")) else [], + commands = query("SELECT * FROM player_commands WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 50", (uuid,)) if (is_admin or perms.get("view_commands")) else [], + deaths = query("SELECT * FROM player_deaths WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 20", (uuid,)) if (is_admin or perms.get("view_deaths")) else [], + teleports = query("SELECT * FROM player_teleports WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 20", (uuid,)), + stats = query("SELECT * FROM player_stats WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 30", (uuid,)), + proxy_events = query("SELECT * FROM proxy_events WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 30", (uuid,)) if (is_admin or perms.get("view_proxy")) else [], + ) + + +# ───────────────────────────────────────────────────────────── +# Sessions +# ───────────────────────────────────────────────────────────── + +@panel.route("/sessions") +@login_required +@perm_required("view_sessions") +def sessions(): + page = max(1, request.args.get("page", 1, type=int)) + player = request.args.get("player", "") + server = request.args.get("server", "") + conditions, args = [], [] + if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%") + if server: conditions.append("server_name = %s"); args.append(server) + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + rows, total, pages = query_paged( + f"SELECT * FROM player_sessions {where} ORDER BY login_time DESC", + f"SELECT COUNT(*) AS c FROM player_sessions {where}", tuple(args), page) + servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM player_sessions ORDER BY server_name")] + return render_template("panel/sessions.html", + rows=rows, total=total, pages=pages, page=page, + player=player, server=server, servers=servers) + + +# ───────────────────────────────────────────────────────────── +# Chat +# ───────────────────────────────────────────────────────────── + +@panel.route("/chat") +@login_required +@perm_required("view_chat") +def chat(): + page = max(1, request.args.get("page", 1, type=int)) + search = request.args.get("q", ""); server = request.args.get("server", "") + date_from = request.args.get("from", ""); date_to = request.args.get("to", "") + conditions, args = [], [] + if search: conditions.append("message LIKE %s"); args.append(f"%{search}%") + if server: conditions.append("server_name = %s"); args.append(server) + if date_from: conditions.append("timestamp >= %s"); args.append(date_from) + if date_to: conditions.append("timestamp <= %s"); args.append(date_to + " 23:59:59") + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + rows, total, pages = query_paged( + f"SELECT * FROM player_chat {where} ORDER BY timestamp DESC", + f"SELECT COUNT(*) AS c FROM player_chat {where}", tuple(args), page) + servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM player_chat ORDER BY server_name")] + return render_template("panel/chat.html", + rows=rows, total=total, pages=pages, page=page, + search=search, server=server, servers=servers, date_from=date_from, date_to=date_to) + + +# ───────────────────────────────────────────────────────────── +# Commands +# ───────────────────────────────────────────────────────────── + +@panel.route("/commands") +@login_required +@perm_required("view_commands") +def commands(): + page = max(1, request.args.get("page", 1, type=int)) + player = request.args.get("player", ""); search = request.args.get("q", ""); server = request.args.get("server", "") + conditions, args = [], [] + if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%") + if search: conditions.append("command LIKE %s"); args.append(f"%{search}%") + if server: conditions.append("server_name = %s"); args.append(server) + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + rows, total, pages = query_paged( + f"SELECT * FROM player_commands {where} ORDER BY timestamp DESC", + f"SELECT COUNT(*) AS c FROM player_commands {where}", tuple(args), page) + servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM player_commands ORDER BY server_name")] + return render_template("panel/commands.html", + rows=rows, total=total, pages=pages, page=page, + player=player, search=search, server=server, servers=servers) + + +# ───────────────────────────────────────────────────────────── +# Tode +# ───────────────────────────────────────────────────────────── + +@panel.route("/deaths") +@login_required +@perm_required("view_deaths") +def deaths(): + page = max(1, request.args.get("page", 1, type=int)) + player = request.args.get("player", ""); cause = request.args.get("cause", "") + conditions, args = [], [] + if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%") + if cause: conditions.append("cause = %s"); args.append(cause) + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + rows, total, pages = query_paged( + f"SELECT * FROM player_deaths {where} ORDER BY timestamp DESC", + f"SELECT COUNT(*) AS c FROM player_deaths {where}", tuple(args), page) + causes = [r["cause"] for r in query("SELECT DISTINCT cause FROM player_deaths ORDER BY cause")] + return render_template("panel/deaths.html", + rows=rows, total=total, pages=pages, page=page, player=player, cause=cause, causes=causes) + + +# ───────────────────────────────────────────────────────────── +# Block-Events +# ───────────────────────────────────────────────────────────── + +@panel.route("/blocks") +@login_required +@perm_required("view_blocks") +def blocks(): + page = max(1, request.args.get("page", 1, type=int)) + event_type = request.args.get("type", ""); player = request.args.get("player", "") + world = request.args.get("world", ""); server = request.args.get("server", ""); block = request.args.get("block", "") + conditions, args = [], [] + if event_type: conditions.append("event_type = %s"); args.append(event_type) + if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%") + if world: conditions.append("world = %s"); args.append(world) + if server: conditions.append("server_name = %s"); args.append(server) + if block: conditions.append("block_type LIKE %s"); args.append(f"%{block}%") + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + rows, total, pages = query_paged( + f"SELECT * FROM block_events {where} ORDER BY timestamp DESC", + f"SELECT COUNT(*) AS c FROM block_events {where}", tuple(args), page) + worlds = [r["world"] for r in query("SELECT DISTINCT world FROM block_events ORDER BY world")] + servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM block_events ORDER BY server_name")] + return render_template("panel/blocks.html", + rows=rows, total=total, pages=pages, page=page, + event_type=event_type, player=player, world=world, server=server, block=block, + worlds=worlds, servers=servers) + + +# ───────────────────────────────────────────────────────────── +# Proxy-Events +# ───────────────────────────────────────────────────────────── + +@panel.route("/proxy") +@login_required +@perm_required("view_proxy") +def proxy(): + page = max(1, request.args.get("page", 1, type=int)) + event_type = request.args.get("type", ""); player = request.args.get("player", "") + conditions, args = [], [] + if event_type: conditions.append("event_type = %s"); args.append(event_type) + if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%") + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + rows, total, pages = query_paged( + f"SELECT * FROM proxy_events {where} ORDER BY timestamp DESC", + f"SELECT COUNT(*) AS c FROM proxy_events {where}", tuple(args), page) + return render_template("panel/proxy.html", + rows=rows, total=total, pages=pages, page=page, event_type=event_type, player=player) + + +# ───────────────────────────────────────────────────────────── +# Server-Events +# ───────────────────────────────────────────────────────────── + +@panel.route("/server-events") +@login_required +@perm_required("view_server_events") +def server_events(): + page = max(1, request.args.get("page", 1, type=int)) + server = request.args.get("server", ""); etype = request.args.get("type", "") + conditions, args = [], [] + if server: conditions.append("server_name = %s"); args.append(server) + if etype: conditions.append("event_type = %s"); args.append(etype) + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + rows, total, pages = query_paged( + f"SELECT * FROM server_events {where} ORDER BY timestamp DESC", + f"SELECT COUNT(*) AS c FROM server_events {where}", tuple(args), page) + servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM server_events ORDER BY server_name")] + etypes = [r["event_type"] for r in query("SELECT DISTINCT event_type FROM server_events ORDER BY event_type")] + return render_template("panel/server_events.html", + rows=rows, total=total, pages=pages, page=page, + server=server, etype=etype, servers=servers, etypes=etypes) + + +# ───────────────────────────────────────────────────────────── +# Berechtigungen (plugin_events) +# ───────────────────────────────────────────────────────────── + +@panel.route("/perms") +@login_required +@perm_required("view_perms") +def perms(): + page = max(1, request.args.get("page", 1, type=int)) + player = request.args.get("player", ""); plugin_filter = request.args.get("plugin", ""); etype = request.args.get("type", "") + conditions, args = [], [] + if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%") + if plugin_filter: conditions.append("plugin_name = %s"); args.append(plugin_filter) + if etype: conditions.append("event_type LIKE %s"); args.append(f"%{etype}%") + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + rows, total, pages = query_paged( + f"SELECT * FROM plugin_events {where} ORDER BY timestamp DESC", + f"SELECT COUNT(*) AS c FROM plugin_events {where}", tuple(args), page) + plugins = [r["plugin_name"] for r in query("SELECT DISTINCT plugin_name FROM plugin_events ORDER BY plugin_name")] + return render_template("panel/perms.html", + rows=rows, total=total, pages=pages, page=page, + player=player, plugin_filter=plugin_filter, etype=etype, plugins=plugins) + + +# ───────────────────────────────────────────────────────────── +# API +# ───────────────────────────────────────────────────────────── + +@panel.route("/api/online") +@login_required +def api_online(): + rows = query(""" + SELECT p.username, ps.server_name, ps.login_time, ps.country + FROM player_sessions ps + JOIN players p ON p.uuid = ps.player_uuid + WHERE ps.logout_time IS NULL ORDER BY ps.login_time DESC + """) + return jsonify([dict(r) for r in rows]) + + +@panel.route("/api/stats") +@login_required +def api_stats(): + return jsonify({ + "players_online": query("SELECT COUNT(*) AS c FROM player_sessions WHERE logout_time IS NULL", fetchone=True)["c"], + }) diff --git a/web/blueprints/site_admin.py b/web/blueprints/site_admin.py new file mode 100644 index 0000000..463a861 --- /dev/null +++ b/web/blueprints/site_admin.py @@ -0,0 +1,221 @@ +""" +MCLogger – Site-Admin-Bereich +Verwaltet alle Gruppen und Nutzer global. +""" +from functools import wraps +from flask import Blueprint, render_template, request, redirect, url_for, session, flash +import panel_db as db + +site_admin = Blueprint("site_admin", __name__, url_prefix="/admin") + + +def admin_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("is_site_admin"): + return redirect(url_for("auth.admin_login")) + return f(*args, **kwargs) + return decorated + + +# ────────────────────────────────────────────────────────────── +# Dashboard +# ────────────────────────────────────────────────────────────── + +@site_admin.route("/") +@admin_required +def dashboard(): + groups = db.list_all_groups() + users = db.list_all_users() + # Für jede Gruppe DB-Status prüfen + for g in groups: + g["has_db"] = db.has_db_configured(g["id"]) + return render_template("admin/dashboard.html", groups=groups, users=users) + + +# ────────────────────────────────────────────────────────────── +# Gruppen verwalten +# ────────────────────────────────────────────────────────────── + +@site_admin.route("/groups") +@admin_required +def groups(): + all_groups = db.list_all_groups() + for g in all_groups: + g["has_db"] = db.has_db_configured(g["id"]) + return render_template("admin/groups.html", groups=all_groups) + + +@site_admin.route("/groups/new", methods=["GET", "POST"]) +@admin_required +def group_new(): + if request.method == "POST": + name = request.form.get("name", "").strip() + desc = request.form.get("description", "").strip() + if not name: + flash("Gruppenname darf nicht leer sein.", "danger") + elif db.get_group_by_name(name): + flash("Eine Gruppe mit diesem Namen existiert bereits.", "danger") + else: + db.create_group(name, desc) + flash(f"Gruppe '{name}' erstellt.", "success") + return redirect(url_for("site_admin.groups")) + return render_template("admin/group_edit.html", group=None) + + +@site_admin.route("/groups//edit", methods=["GET", "POST"]) +@admin_required +def group_edit(group_id): + group = db.get_group_by_id(group_id) + if not group: + flash("Gruppe nicht gefunden.", "danger") + return redirect(url_for("site_admin.groups")) + if request.method == "POST": + name = request.form.get("name", "").strip() + desc = request.form.get("description", "").strip() + if not name: + flash("Gruppenname darf nicht leer sein.", "danger") + else: + db.update_group(group_id, name, desc) + flash("Gruppe aktualisiert.", "success") + return redirect(url_for("site_admin.groups")) + return render_template("admin/group_edit.html", group=group) + + +@site_admin.route("/groups//delete", methods=["POST"]) +@admin_required +def group_delete(group_id): + db.delete_group(group_id) + flash("Gruppe gelöscht.", "success") + return redirect(url_for("site_admin.groups")) + + +@site_admin.route("/groups//members") +@admin_required +def group_members(group_id): + group = db.get_group_by_id(group_id) + members = db.get_group_members(group_id) + all_users = db.list_all_users() + member_ids = {m["id"] for m in members} + non_members = [u for u in all_users if u["id"] not in member_ids] + return render_template("admin/group_members.html", + group=group, members=members, non_members=non_members) + + +@site_admin.route("/groups//members/add", methods=["POST"]) +@admin_required +def group_member_add(group_id): + user_id = request.form.get("user_id", type=int) + role = request.form.get("role", "member") + if user_id: + db.add_group_member(user_id, group_id, role) + flash("Mitglied hinzugefügt.", "success") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + + +@site_admin.route("/groups//members//remove", methods=["POST"]) +@admin_required +def group_member_remove(group_id, user_id): + db.remove_group_member(user_id, group_id) + flash("Mitglied entfernt.", "success") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + + +# ────────────────────────────────────────────────────────────── +# Nutzer verwalten +# ────────────────────────────────────────────────────────────── + +@site_admin.route("/users") +@admin_required +def users(): + return render_template("admin/users.html", users=db.list_all_users()) + + +@site_admin.route("/users/new", methods=["GET", "POST"]) +@admin_required +def user_new(): + if request.method == "POST": + username = request.form.get("username", "").strip() + email = request.form.get("email", "").strip() + password = request.form.get("password", "") + is_site_admin = request.form.get("is_site_admin") == "1" + if not username or not email or not password: + flash("Alle Felder sind Pflichtfelder.", "danger") + elif db.get_user_by_username(username): + flash("Benutzername bereits vergeben.", "danger") + else: + db.create_user(username, email, password, is_site_admin) + flash(f"Nutzer '{username}' erstellt.", "success") + return redirect(url_for("site_admin.users")) + return render_template("admin/user_edit.html", user=None) + + +@site_admin.route("/users//edit", methods=["GET", "POST"]) +@admin_required +def user_edit(user_id): + user = db.get_user_by_id(user_id) + if not user: + flash("Nutzer nicht gefunden.", "danger") + return redirect(url_for("site_admin.users")) + if request.method == "POST": + username = request.form.get("username", "").strip() + email = request.form.get("email", "").strip() + is_site_admin = request.form.get("is_site_admin") == "1" + new_password = request.form.get("new_password", "") + db.update_user(user_id, username, email, is_site_admin) + if new_password: + db.change_password(user_id, new_password) + flash("Passwort geändert.", "info") + flash("Nutzer aktualisiert.", "success") + return redirect(url_for("site_admin.users")) + return render_template("admin/user_edit.html", user=user) + + +@site_admin.route("/users//delete", methods=["POST"]) +@admin_required +def user_delete(user_id): + if user_id == session.get("user_id"): + flash("Du kannst dich nicht selbst löschen.", "danger") + else: + db.delete_user(user_id) + flash("Nutzer gelöscht.", "success") + return redirect(url_for("site_admin.users")) + + +# ────────────────────────────────────────────────────────────── +# Als Gruppe anzeigen (Site-Admin liest Gruppen-DB) +# ────────────────────────────────────────────────────────────── + +@site_admin.route("/view-group/") +@admin_required +def view_group(group_id): + """Site-Admin wechselt temporär in eine Grup­pe, um deren MC-Daten zu sehen.""" + group = db.get_group_by_id(group_id) + if not group: + flash("Gruppe nicht gefunden.", "danger") + return redirect(url_for("site_admin.dashboard")) + if not db.has_db_configured(group_id): + flash("Für diese Gruppe ist noch keine Datenbank konfiguriert.", "warning") + return redirect(url_for("site_admin.dashboard")) + # Alle Berechtigungen als Admin + all_perms = {k: True for k in ["view_dashboard","view_players","view_sessions", + "view_chat","view_commands","view_deaths","view_blocks", + "view_proxy","view_server_events","view_perms"]} + session["group_id"] = group_id + session["group_name"] = group["name"] + session["role"] = "admin" + session["permissions"] = all_perms + session["admin_viewing"] = True + return redirect(url_for("panel.dashboard")) + + +@site_admin.route("/stop-view") +@admin_required +def stop_view(): + """Kehrt zum Site-Admin-Dashboard zurück.""" + session.pop("group_id", None) + session.pop("group_name", None) + session.pop("role", None) + session.pop("permissions", None) + session.pop("admin_viewing", None) + return redirect(url_for("site_admin.dashboard")) diff --git a/web/config.py b/web/config.py new file mode 100644 index 0000000..ea35334 --- /dev/null +++ b/web/config.py @@ -0,0 +1,46 @@ +""" +MCLogger – Konfiguration +Alle Einstellungen über ENV-Variablen (Coolify-kompatibel). +""" +import os + + +class Config: + # ── Flask ────────────────────────────────────────────────── + SECRET_KEY = os.getenv("SECRET_KEY", "change-me-use-a-long-random-string-min-32-chars") + HOST = os.getenv("HOST", "0.0.0.0") + PORT = int(os.getenv("PORT", "5000")) + DEBUG = os.getenv("DEBUG", "false").lower() == "true" + + # ── Panel-Datenbank (Nutzer, Gruppen, Mitgliedschaften) ──── + PANEL_DB_HOST = os.getenv("PANEL_DB_HOST", "localhost") + PANEL_DB_PORT = int(os.getenv("PANEL_DB_PORT", "3306")) + PANEL_DB_USER = os.getenv("PANEL_DB_USER", "root") + PANEL_DB_PASSWORD = os.getenv("PANEL_DB_PASSWORD", "") + PANEL_DB_NAME = os.getenv("PANEL_DB_NAME", "mclogger_panel") + + # ── Credentials-Datenbank (verschlüsselte MC-DB-Zugangsdaten) ── + CREDS_DB_HOST = os.getenv("CREDS_DB_HOST", os.getenv("PANEL_DB_HOST", "localhost")) + CREDS_DB_PORT = int(os.getenv("CREDS_DB_PORT", os.getenv("PANEL_DB_PORT", "3306"))) + CREDS_DB_USER = os.getenv("CREDS_DB_USER", os.getenv("PANEL_DB_USER", "root")) + CREDS_DB_PASSWORD = os.getenv("CREDS_DB_PASSWORD", os.getenv("PANEL_DB_PASSWORD", "")) + CREDS_DB_NAME = os.getenv("CREDS_DB_NAME", "mclogger_creds") + + # ── Sicherheit ──────────────────────────────────────────── + PASSWORD_PEPPER = os.getenv("PASSWORD_PEPPER", "change-me-global-pepper-secret-never-change") + # Generieren: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + FERNET_KEY = os.getenv("FERNET_KEY", "") + + # ── Standard-Berechtigungen neuer Gruppenmitglieder ─────── + DEFAULT_PERMISSIONS = { + "view_dashboard": True, + "view_players": True, + "view_sessions": True, + "view_chat": True, + "view_commands": True, + "view_deaths": True, + "view_blocks": True, + "view_proxy": False, + "view_server_events": False, + "view_perms": False, + } diff --git a/web/crypto.py b/web/crypto.py new file mode 100644 index 0000000..c807d35 --- /dev/null +++ b/web/crypto.py @@ -0,0 +1,63 @@ +""" +MCLogger – Kryptographie-Utilities +- Passwort-Hashing: PBKDF2-HMAC-SHA256 mit Salt (pro Nutzer) + Pepper (global, via ENV) +- DB-Credential-Verschlüsselung: Fernet (symmetrisch, Schlüssel via ENV) +""" +import hashlib +import os +from cryptography.fernet import Fernet +from config import Config + + +# ───────────────────────────────────────────────────────────── +# Passwort-Hashing +# ───────────────────────────────────────────────────────────── + +def generate_salt() -> str: + """Generiert einen zufälligen 32-Byte Hex-Salt.""" + return os.urandom(32).hex() + + +def hash_password(password: str, salt: str) -> str: + """ + Hasht ein Passwort mit PBKDF2-HMAC-SHA256. + Verwendet: salt (pro Nutzer) + pepper (global aus ENV) + """ + dk = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + (salt + Config.PASSWORD_PEPPER).encode("utf-8"), + iterations=260_000, + ) + return dk.hex() + + +def verify_password(password: str, salt: str, stored_hash: str) -> bool: + """Prüft ob ein Passwort korrekt ist.""" + return hash_password(password, salt) == stored_hash + + +# ───────────────────────────────────────────────────────────── +# Fernet-Verschlüsselung (für DB-Zugangsdaten) +# ───────────────────────────────────────────────────────────── + +def _get_fernet() -> Fernet: + key = Config.FERNET_KEY + if not key: + raise RuntimeError( + "FERNET_KEY ist nicht gesetzt! " + "Generieren: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" + ) + if isinstance(key, str): + key = key.encode() + return Fernet(key) + + +def encrypt_str(plaintext: str) -> str: + """Verschlüsselt einen String mit Fernet.""" + return _get_fernet().encrypt(plaintext.encode("utf-8")).decode("utf-8") + + +def decrypt_str(ciphertext: str) -> str: + """Entschlüsselt einen Fernet-verschlüsselten String.""" + return _get_fernet().decrypt(ciphertext.encode("utf-8")).decode("utf-8") diff --git a/web/docker-compose.yml b/web/docker-compose.yml new file mode 100644 index 0000000..209a26d --- /dev/null +++ b/web/docker-compose.yml @@ -0,0 +1,46 @@ +version: "3.9" + +services: + mclogger-panel: + build: . + image: mclogger-panel:latest + container_name: mclogger-panel + restart: unless-stopped + ports: + - "${PORT:-5000}:5000" + environment: + # ── Flask ────────────────────────────────────────────── + SECRET_KEY: "${SECRET_KEY}" + + # ── Panel-Datenbank (Benutzer / Gruppen) ─────────────── + PANEL_DB_HOST: "${PANEL_DB_HOST:-localhost}" + PANEL_DB_PORT: "${PANEL_DB_PORT:-3306}" + PANEL_DB_USER: "${PANEL_DB_USER:-mclogger_panel}" + PANEL_DB_PASSWORD: "${PANEL_DB_PASSWORD}" + PANEL_DB_NAME: "${PANEL_DB_NAME:-mclogger_panel}" + + # ── Credentials-Datenbank (verschlüsselte MC-DB-Daten) ─ + CREDS_DB_HOST: "${CREDS_DB_HOST:-localhost}" + CREDS_DB_PORT: "${CREDS_DB_PORT:-3306}" + CREDS_DB_USER: "${CREDS_DB_USER:-mclogger_creds}" + CREDS_DB_PASSWORD: "${CREDS_DB_PASSWORD}" + CREDS_DB_NAME: "${CREDS_DB_NAME:-mclogger_creds}" + + # ── Sicherheit ────────────────────────────────────────── + # Fernet-Schlüssel (32 URL-safe base64 bytes): + # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + FERNET_KEY: "${FERNET_KEY}" + # Pepper für Passwort-Hashing (beliebige lange Zeichenkette) + PASSWORD_PEPPER: "${PASSWORD_PEPPER}" + + # ── Server ────────────────────────────────────────────── + HOST: "0.0.0.0" + PORT: "5000" + DEBUG: "${DEBUG:-false}" + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/login"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s diff --git a/web/panel_db.py b/web/panel_db.py new file mode 100644 index 0000000..9d11e0f --- /dev/null +++ b/web/panel_db.py @@ -0,0 +1,349 @@ +""" +MCLogger – Panel-Datenbank-Operationen +Verwaltet Nutzer, Gruppen, Mitgliedschaften (PANEL_DB) +und verschlüsselte MC-DB-Zugangsdaten (CREDS_DB). +""" +import json +import pymysql +import pymysql.cursors +from config import Config +from crypto import generate_salt, hash_password, verify_password, encrypt_str, decrypt_str + + +# ───────────────────────────────────────────────────────────── +# Datenbankverbindungen +# ───────────────────────────────────────────────────────────── + +def get_panel_db(): + return pymysql.connect( + host=Config.PANEL_DB_HOST, + port=Config.PANEL_DB_PORT, + user=Config.PANEL_DB_USER, + password=Config.PANEL_DB_PASSWORD, + database=Config.PANEL_DB_NAME, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + autocommit=True, + ) + + +def get_creds_db(): + return pymysql.connect( + host=Config.CREDS_DB_HOST, + port=Config.CREDS_DB_PORT, + user=Config.CREDS_DB_USER, + password=Config.CREDS_DB_PASSWORD, + database=Config.CREDS_DB_NAME, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + autocommit=True, + ) + + +def _panel_query(sql, args=None, fetchone=False, write=False): + conn = get_panel_db() + try: + with conn.cursor() as cur: + cur.execute(sql, args or ()) + if write: + return cur.lastrowid + return cur.fetchone() if fetchone else cur.fetchall() + finally: + conn.close() + + +def _creds_query(sql, args=None, fetchone=False, write=False): + conn = get_creds_db() + try: + with conn.cursor() as cur: + cur.execute(sql, args or ()) + if write: + return cur.lastrowid + return cur.fetchone() if fetchone else cur.fetchall() + finally: + conn.close() + + +# ───────────────────────────────────────────────────────────── +# Initialisierung – Tabellen anlegen +# ───────────────────────────────────────────────────────────── + +PANEL_SCHEMA = [ + """CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + salt VARCHAR(64) NOT NULL, + is_site_admin TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", + + """CREATE TABLE IF NOT EXISTS user_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", + + """CREATE TABLE IF NOT EXISTS group_members ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + group_id INT NOT NULL, + role ENUM('admin','member') DEFAULT 'member', + permissions JSON, + joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_user_group (user_id, group_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", +] + +CREDS_SCHEMA = [ + """CREATE TABLE IF NOT EXISTS group_databases ( + id INT AUTO_INCREMENT PRIMARY KEY, + group_id INT UNIQUE NOT NULL, + enc_host TEXT NOT NULL, + enc_port TEXT NOT NULL, + enc_user TEXT NOT NULL, + enc_password TEXT NOT NULL, + enc_database TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", +] + + +def init_databases(): + """Erstellt alle benötigten Tabellen falls nicht vorhanden.""" + panel = get_panel_db() + try: + with panel.cursor() as cur: + for stmt in PANEL_SCHEMA: + cur.execute(stmt) + finally: + panel.close() + + creds = get_creds_db() + try: + with creds.cursor() as cur: + for stmt in CREDS_SCHEMA: + cur.execute(stmt) + finally: + creds.close() + + +# ───────────────────────────────────────────────────────────── +# Nutzer +# ───────────────────────────────────────────────────────────── + +def create_user(username: str, email: str, password: str, is_site_admin: bool = False) -> int: + salt = generate_salt() + pw_hash = hash_password(password, salt) + return _panel_query( + "INSERT INTO users (username, email, password_hash, salt, is_site_admin) VALUES (%s,%s,%s,%s,%s)", + (username, email, pw_hash, salt, int(is_site_admin)), write=True + ) + + +def get_user_by_username(username: str): + return _panel_query("SELECT * FROM users WHERE username=%s", (username,), fetchone=True) + + +def get_user_by_id(user_id: int): + return _panel_query("SELECT * FROM users WHERE id=%s", (user_id,), fetchone=True) + + +def update_user(user_id: int, username: str, email: str, is_site_admin: bool): + _panel_query( + "UPDATE users SET username=%s, email=%s, is_site_admin=%s WHERE id=%s", + (username, email, int(is_site_admin), user_id), write=True + ) + + +def change_password(user_id: int, new_password: str): + salt = generate_salt() + pw_hash = hash_password(new_password, salt) + _panel_query( + "UPDATE users SET password_hash=%s, salt=%s WHERE id=%s", + (pw_hash, salt, user_id), write=True + ) + + +def delete_user(user_id: int): + _panel_query("DELETE FROM users WHERE id=%s", (user_id,), write=True) + + +def check_login(username: str, password: str): + """Prüft Anmeldedaten. Gibt den Nutzer zurück oder None.""" + user = get_user_by_username(username) + if not user: + return None + if not verify_password(password, user["salt"], user["password_hash"]): + return None + _panel_query("UPDATE users SET last_login=NOW() WHERE id=%s", (user["id"],), write=True) + return user + + +def list_all_users(): + return _panel_query( + "SELECT u.*, COUNT(gm.group_id) AS group_count " + "FROM users u LEFT JOIN group_members gm ON gm.user_id=u.id " + "GROUP BY u.id ORDER BY u.username" + ) + + +# ───────────────────────────────────────────────────────────── +# Gruppen +# ───────────────────────────────────────────────────────────── + +def create_group(name: str, description: str = "") -> int: + return _panel_query( + "INSERT INTO user_groups (name, description) VALUES (%s,%s)", + (name, description), write=True + ) + + +def get_group_by_id(group_id: int): + return _panel_query("SELECT * FROM user_groups WHERE id=%s", (group_id,), fetchone=True) + + +def get_group_by_name(name: str): + return _panel_query("SELECT * FROM user_groups WHERE name=%s", (name,), fetchone=True) + + +def update_group(group_id: int, name: str, description: str): + _panel_query( + "UPDATE user_groups SET name=%s, description=%s WHERE id=%s", + (name, description, group_id), write=True + ) + + +def delete_group(group_id: int): + _panel_query("DELETE FROM user_groups WHERE id=%s", (group_id,), write=True) + + +def list_all_groups(): + return _panel_query( + "SELECT g.*, COUNT(gm.user_id) AS member_count " + "FROM user_groups g LEFT JOIN group_members gm ON gm.group_id=g.id " + "GROUP BY g.id ORDER BY g.name" + ) + + +# ───────────────────────────────────────────────────────────── +# Gruppenmitgliedschaften +# ───────────────────────────────────────────────────────────── + +def get_user_groups(user_id: int): + return _panel_query( + "SELECT g.*, gm.role, gm.permissions " + "FROM user_groups g " + "JOIN group_members gm ON gm.group_id=g.id " + "WHERE gm.user_id=%s ORDER BY g.name", + (user_id,) + ) + + +def get_group_member(user_id: int, group_id: int): + return _panel_query( + "SELECT * FROM group_members WHERE user_id=%s AND group_id=%s", + (user_id, group_id), fetchone=True + ) + + +def get_group_members(group_id: int): + return _panel_query( + "SELECT u.id, u.username, u.email, u.last_login, gm.role, gm.permissions, gm.joined_at " + "FROM group_members gm " + "JOIN users u ON u.id=gm.user_id " + "WHERE gm.group_id=%s ORDER BY gm.role DESC, u.username", + (group_id,) + ) + + +def add_group_member(user_id: int, group_id: int, role: str = "member", permissions: dict = None): + if permissions is None: + permissions = Config.DEFAULT_PERMISSIONS + _panel_query( + "INSERT INTO group_members (user_id, group_id, role, permissions) VALUES (%s,%s,%s,%s) " + "ON DUPLICATE KEY UPDATE role=VALUES(role), permissions=VALUES(permissions)", + (user_id, group_id, role, json.dumps(permissions)), write=True + ) + + +def update_member(user_id: int, group_id: int, role: str, permissions: dict): + _panel_query( + "UPDATE group_members SET role=%s, permissions=%s WHERE user_id=%s AND group_id=%s", + (role, json.dumps(permissions), user_id, group_id), write=True + ) + + +def remove_group_member(user_id: int, group_id: int): + _panel_query( + "DELETE FROM group_members WHERE user_id=%s AND group_id=%s", + (user_id, group_id), write=True + ) + + +def get_permissions(user_id: int, group_id: int) -> dict: + """Gibt die Berechtigungen des Nutzers in der Gruppe zurück.""" + member = get_group_member(user_id, group_id) + if not member: + return {} + raw = member.get("permissions") + if isinstance(raw, str): + return json.loads(raw) + if isinstance(raw, dict): + return raw + return {} + + +# ───────────────────────────────────────────────────────────── +# MC-Datenbank-Zugangsdaten (verschlüsselt) +# ───────────────────────────────────────────────────────────── + +def set_group_db_creds(group_id: int, host: str, port: int, user: str, password: str, database: str): + """Verschlüsselt und speichert die MC-DB-Zugangsdaten einer Gruppe.""" + _creds_query( + "INSERT INTO group_databases (group_id, enc_host, enc_port, enc_user, enc_password, enc_database) " + "VALUES (%s,%s,%s,%s,%s,%s) " + "ON DUPLICATE KEY UPDATE enc_host=VALUES(enc_host), enc_port=VALUES(enc_port), " + "enc_user=VALUES(enc_user), enc_password=VALUES(enc_password), enc_database=VALUES(enc_database)", + (group_id, + encrypt_str(host), + encrypt_str(str(port)), + encrypt_str(user), + encrypt_str(password), + encrypt_str(database)), + write=True + ) + + +def get_group_db_creds(group_id: int) -> dict | None: + """Gibt die entschlüsselten MC-DB-Zugangsdaten zurück oder None.""" + row = _creds_query( + "SELECT * FROM group_databases WHERE group_id=%s", + (group_id,), fetchone=True + ) + if not row: + return None + return { + "host": decrypt_str(row["enc_host"]), + "port": int(decrypt_str(row["enc_port"])), + "user": decrypt_str(row["enc_user"]), + "password": decrypt_str(row["enc_password"]), + "database": decrypt_str(row["enc_database"]), + } + + +def delete_group_db_creds(group_id: int): + _creds_query("DELETE FROM group_databases WHERE group_id=%s", (group_id,), write=True) + + +def has_db_configured(group_id: int) -> bool: + row = _creds_query( + "SELECT id FROM group_databases WHERE group_id=%s", + (group_id,), fetchone=True + ) + return row is not None diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..14cc2a3 --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.1.0 +PyMySQL==1.1.1 +cryptography==42.0.8 +gunicorn==22.0.0 diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..1753b98 --- /dev/null +++ b/web/static/css/style.css @@ -0,0 +1,230 @@ +/* ============================================================ + MCLogger – Admin Interface CSS + Author: SimolZimol + ============================================================ */ + +:root { + --sidebar-width: 230px; + --sidebar-bg: #0f1117; + --sidebar-border: #1e2230; + --topbar-bg: #13161f; + --content-bg: #181c27; + --card-bg: #1e2230; + --card-border: #2a2f42; + --text-muted-custom: #6b7280; + --accent-green: #1db954; +} + +/* ── Layout ─────────────────────────────────────────────── */ +html, body { + height: 100%; + overflow: hidden; + background: var(--content-bg); + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + font-size: 14px; +} + +#wrapper { + height: 100vh; + overflow: hidden; +} + +/* ── Sidebar ─────────────────────────────────────────────── */ +#sidebar { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + background: var(--sidebar-bg); + border-right: 1px solid var(--sidebar-border); + height: 100vh; + overflow-y: auto; + overflow-x: hidden; + transition: width .25s ease, min-width .25s ease; +} + +#sidebar.collapsed { + width: 64px; + min-width: 64px; +} + +#sidebar.collapsed .sidebar-brand div, +#sidebar.collapsed .sidebar-brand small, +#sidebar.collapsed .nav-link span { + display: none; +} + +.sidebar-brand { + padding: .25rem 0; +} + +#sidebar .nav-link { + color: #9ca3af; + border-radius: 6px; + padding: .45rem .75rem; + font-size: .875rem; + display: flex; + align-items: center; + gap: .6rem; + transition: background .15s, color .15s; + white-space: nowrap; +} + +#sidebar .nav-link i { + font-size: 1rem; + flex-shrink: 0; +} + +#sidebar .nav-link:hover { background: rgba(255,255,255,.05); color: #e5e7eb; } +#sidebar .nav-link.active { background: rgba(29,185,84,.15); color: var(--accent-green); } + +/* ── Topbar ──────────────────────────────────────────────── */ +.topbar { + background: var(--topbar-bg); + border-bottom: 1px solid var(--sidebar-border); + height: 52px; + flex-shrink: 0; +} + +/* ── Content ─────────────────────────────────────────────── */ +#page-content { + display: flex; + flex-direction: column; + background: var(--content-bg); + height: 100vh; +} + +main { + flex: 1; + overflow-y: auto; +} + +/* ── Cards ───────────────────────────────────────────────── */ +.card { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 10px; +} + +.card-header { + background: rgba(0,0,0,.2); + border-bottom: 1px solid var(--card-border); + font-size: .85rem; + font-weight: 600; + color: #d1d5db; +} + +/* ── Statistik-Karten ────────────────────────────────────── */ +.stat-card { transition: transform .15s; cursor: default; } +.stat-card:hover { transform: translateY(-2px); } + +.stat-icon { + width: 44px; + height: 44px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 1.3rem; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 700; + line-height: 1; + color: #f3f4f6; +} + +.stat-label { + font-size: .72rem; + color: var(--text-muted-custom); + margin-top: 2px; +} + +/* ── Tabellen ────────────────────────────────────────────── */ +.table { + color: #d1d5db; + font-size: .8rem; +} + +.table > thead { + font-size: .75rem; + letter-spacing: .03em; + color: #9ca3af; +} + +.table-hover > tbody > tr:hover > td { + background: rgba(255,255,255,.04); +} + +.table-dark { + --bs-table-bg: rgba(0,0,0,.3); +} + +/* ── Badges ──────────────────────────────────────────────── */ +.badge { font-size: .7rem; font-weight: 500; } + +/* ── Inputs ──────────────────────────────────────────────── */ +.form-control, .form-select { + background-color: #111827; + border-color: var(--card-border); + color: #e5e7eb; + font-size: .8rem; +} + +.form-control:focus, .form-select:focus { + border-color: var(--accent-green); + box-shadow: 0 0 0 2px rgba(29,185,84,.25); + background-color: #111827; + color: #f9fafb; +} + +.form-control::placeholder { color: #6b7280; } + +/* ── Buttons ─────────────────────────────────────────────── */ +.btn-success { background-color: var(--accent-green); border-color: var(--accent-green); } +.btn-success:hover { background-color: #17a34a; border-color: #17a34a; } + +/* ── Pagination ──────────────────────────────────────────── */ +.page-link { + background-color: var(--card-bg); + border-color: var(--card-border); + color: #9ca3af; + font-size: .8rem; +} +.page-link:hover { background-color: rgba(255,255,255,.07); color: #f3f4f6; } +.page-item.active .page-link { background-color: var(--accent-green); border-color: var(--accent-green); color: #000; } +.page-item.disabled .page-link { background-color: transparent; } + +/* ── Login-Seite ─────────────────────────────────────────── */ +body .card.shadow-lg { + background: #1e2230; + border: 1px solid #2a2f42; +} + +/* ── Scrollbars ──────────────────────────────────────────── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #4b5563; } + +/* ── Diverse ─────────────────────────────────────────────── */ +.blink { + animation: blink-anim 1.5s infinite; +} +@keyframes blink-anim { + 0%, 100% { opacity: 1; } + 50% { opacity: .2; } +} + +.font-monospace { font-family: 'Consolas', 'Cascadia Code', monospace !important; } + +.text-truncate { max-width: 250px; } + +/* Chart.js Canvas */ +canvas { max-height: 250px; } + +/* Alert */ +.alert { font-size: .85rem; } + +/* sticky-top in dark scrollable containers */ +.sticky-top { z-index: 1; } diff --git a/web/static/js/main.js b/web/static/js/main.js new file mode 100644 index 0000000..62d7483 --- /dev/null +++ b/web/static/js/main.js @@ -0,0 +1,97 @@ +/* ============================================================ + MCLogger – main.js + Author: SimolZimol + ============================================================ */ + +// ── Sidebar Toggle ──────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + const btn = document.getElementById('sidebarToggle'); + const sidebar = document.getElementById('sidebar'); + + if (btn && sidebar) { + btn.addEventListener('click', () => { + sidebar.classList.toggle('collapsed'); + localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed')); + }); + + // Zustand beim Laden wiederherstellen + if (localStorage.getItem('sidebar-collapsed') === 'true') { + sidebar.classList.add('collapsed'); + } + } + + // Online-Count aktualisieren + updateOnlineCount(); + setInterval(updateOnlineCount, 30_000); + + // Tooltips initialisieren + document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { + new bootstrap.Tooltip(el); + }); + + // Automatisch Tabellen sortieren ermöglichen + initTableSort(); +}); + +// ── Online-Count API ────────────────────────────────────── +function updateOnlineCount() { + fetch('/api/online') + .then(r => r.json()) + .then(data => { + const el = document.getElementById('online-count'); + if (el) el.textContent = data.length; + }) + .catch(() => {/* Ignorieren wenn nicht eingeloggt */}); +} + +// ── Einfache Tabellen-Sortierung ────────────────────────── +function initTableSort() { + document.querySelectorAll('th[data-sort]').forEach(th => { + th.style.cursor = 'pointer'; + th.addEventListener('click', () => { + const table = th.closest('table'); + const idx = Array.from(th.parentNode.children).indexOf(th); + const asc = th.dataset.order !== 'asc'; + th.dataset.order = asc ? 'asc' : 'desc'; + + const rows = Array.from(table.querySelectorAll('tbody tr')); + rows.sort((a, b) => { + const av = a.cells[idx]?.textContent.trim() ?? ''; + const bv = b.cells[idx]?.textContent.trim() ?? ''; + return asc ? av.localeCompare(bv, 'de', {numeric: true}) : bv.localeCompare(av, 'de', {numeric: true}); + }); + const tbody = table.querySelector('tbody'); + rows.forEach(r => tbody.appendChild(r)); + }); + }); +} + +// ── Formatierung ────────────────────────────────────────── +function fmtDuration(sec) { + sec = parseInt(sec) || 0; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + if (h) return `${h}h ${m}m`; + if (m) return `${m}m ${s}s`; + return `${s}s`; +} + +// ── Live-Chat-Reload (optional) ─────────────────────────── +if (window.location.pathname === '/chat') { + // Kein automatisches Reload im Chat (würde Filter zurücksetzen) +} + +// ── Kopieren in Zwischenablage ──────────────────────────── +document.querySelectorAll('.copy-btn').forEach(btn => { + btn.addEventListener('click', () => { + const target = document.querySelector(btn.dataset.target); + if (target) { + navigator.clipboard.writeText(target.textContent.trim()) + .then(() => { + btn.innerHTML = ''; + setTimeout(() => btn.innerHTML = '', 1500); + }); + } + }); +}); diff --git a/web/templates/_pagination.html b/web/templates/_pagination.html new file mode 100644 index 0000000..eb6c1aa --- /dev/null +++ b/web/templates/_pagination.html @@ -0,0 +1,22 @@ +{% if pages > 1 %} + +{% endif %} diff --git a/web/templates/admin/base.html b/web/templates/admin/base.html new file mode 100644 index 0000000..513b168 --- /dev/null +++ b/web/templates/admin/base.html @@ -0,0 +1,43 @@ + + + + + + {% block title %}Site Admin{% endblock %} — MCLogger + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for cat, msg in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ +{% block scripts %}{% endblock %} + + diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html new file mode 100644 index 0000000..b1db800 --- /dev/null +++ b/web/templates/admin/dashboard.html @@ -0,0 +1,121 @@ +{% extends "admin/base.html" %} +{% block title %}Dashboard{% endblock %} +{% block content %} +

Site Admin Dashboard

+ +
+
+
+
+
{{ stats.group_count }}
+
Gruppen
+
+
+
+
+
+
+
{{ stats.user_count }}
+
Benutzer
+
+
+
+
+
+
+
{{ stats.db_configured }}
+
DBs konfiguriert
+
+
+
+
+
+
+
{{ stats.admin_count }}
+
Site Admins
+
+
+
+
+ +
+
+
+
+ Gruppen + + Neu + +
+
+ + + + {% for g in groups %} + + + + + + + {% else %} + + {% endfor %} + +
NameMitgliederDB
{{ g.name }}{{ g.member_count }} + {% if g.has_db %} + Konfiguriert + {% else %} + Keine + {% endif %} + + + + + + + +
Keine Gruppen vorhanden
+
+ +
+
+ +
+
+
+ Benutzer + + Neu + +
+
+ + + + {% for u in users %} + + + + + + + {% else %} + + {% endfor %} + +
BenutzerGruppenAdmin
{{ u.username }}{{ u.group_count }}{% if u.is_site_admin %}{% endif %} + + + +
Keine Benutzer vorhanden
+
+ +
+
+
+{% endblock %} diff --git a/web/templates/admin/group_edit.html b/web/templates/admin/group_edit.html new file mode 100644 index 0000000..416062e --- /dev/null +++ b/web/templates/admin/group_edit.html @@ -0,0 +1,36 @@ +{% extends "admin/base.html" %} +{% block title %}{{ 'Gruppe bearbeiten' if group else 'Neue Gruppe' }}{% endblock %} +{% block content %} +
+ + + +

{{ 'Gruppe bearbeiten' if group else 'Neue Gruppe erstellen' }}

+
+ +
+
+
+
+
+
+ + +
+
+ + +
+
+ + Abbrechen +
+
+
+
+
+
+{% endblock %} diff --git a/web/templates/admin/group_members.html b/web/templates/admin/group_members.html new file mode 100644 index 0000000..2963720 --- /dev/null +++ b/web/templates/admin/group_members.html @@ -0,0 +1,86 @@ +{% extends "admin/base.html" %} +{% block title %}Mitglieder – {{ group.name }}{% endblock %} +{% block content %} +
+ + + +

Mitglieder: {{ group.name }}

+
+ +
+ +
+
+
Aktuelle Mitglieder ({{ members|length }})
+
+ + + + {% for m in members %} + + + + + + {% else %} + + {% endfor %} + +
BenutzerRolleAktionen
{{ m.username }} + {% if m.role == 'admin' %} + Admin + {% else %} + Member + {% endif %} + +
+ +
+
+ +
+
Keine Mitglieder
+
+
+
+ + +
+
+
Benutzer hinzufügen
+
+ {% if non_members %} +
+
+ + +
+
+ + +
+ +
+ {% else %} +

Alle Benutzer sind bereits Mitglied.

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/web/templates/admin/groups.html b/web/templates/admin/groups.html new file mode 100644 index 0000000..e3f7414 --- /dev/null +++ b/web/templates/admin/groups.html @@ -0,0 +1,59 @@ +{% extends "admin/base.html" %} +{% block title %}Gruppen{% endblock %} +{% block content %} +
+

Gruppen

+ + Neue Gruppe + +
+ +
+
+ + + + + + + + {% for g in groups %} + + + + + + + + + + {% else %} + + {% endfor %} + +
IDNameBeschreibungMitgliederDBErstelltAktionen
{{ g.id }}{{ g.name }}{{ g.description or '—' }}{{ g.member_count }} + {% if g.has_db %} + Konfiguriert + {% else %} + Keine DB + {% endif %} + {{ g.created_at | fmt_dt }} + + + + + + + + + +
+ +
+
Noch keine Gruppen vorhanden.
+
+
+{% endblock %} diff --git a/web/templates/admin/user_edit.html b/web/templates/admin/user_edit.html new file mode 100644 index 0000000..2deafc2 --- /dev/null +++ b/web/templates/admin/user_edit.html @@ -0,0 +1,50 @@ +{% extends "admin/base.html" %} +{% block title %}{{ 'Benutzer bearbeiten' if user else 'Neuer Benutzer' }}{% endblock %} +{% block content %} +
+ + + +

{{ 'Benutzer bearbeiten: ' ~ user.username if user else 'Neuer Benutzer' }}

+
+ +
+
+
+
+
+
+ + +
+
+ + + {% if not user %} +
Mindestens 8 Zeichen empfohlen.
+ {% endif %} +
+
+
+ + +
+
+
+ + Abbrechen +
+
+
+
+
+
+{% endblock %} diff --git a/web/templates/admin/users.html b/web/templates/admin/users.html new file mode 100644 index 0000000..0895469 --- /dev/null +++ b/web/templates/admin/users.html @@ -0,0 +1,53 @@ +{% extends "admin/base.html" %} +{% block title %}Benutzer{% endblock %} +{% block content %} +
+

Benutzer

+ + Neuer Benutzer + +
+ +
+
+ + + + + + {% for u in users %} + + + + + + + + {% else %} + + {% endfor %} + +
BenutzerGruppenSite AdminErstelltAktionen
{{ u.username }} + {% for g in u.groups %} + {{ g.name }} + {% if g.role == 'admin' %}{% endif %} + + {% else %}Keine{% endfor %} + + {% if u.is_site_admin %} + Site Admin + {% else %}—{% endif %} + {{ u.created_at | fmt_dt }} + + + +
+ +
+
Keine Benutzer vorhanden.
+
+
+{% endblock %} diff --git a/web/templates/auth/admin_login.html b/web/templates/auth/admin_login.html new file mode 100644 index 0000000..5879da4 --- /dev/null +++ b/web/templates/auth/admin_login.html @@ -0,0 +1,63 @@ + + + + + + MCLogger – Site Admin Login + + + + + + + + + diff --git a/web/templates/auth/login.html b/web/templates/auth/login.html new file mode 100644 index 0000000..7231159 --- /dev/null +++ b/web/templates/auth/login.html @@ -0,0 +1,63 @@ + + + + + + MCLogger – Login + + + + + + + + + diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..10a9061 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,173 @@ + + + + + + {% block title %}MCLogger{% endblock %} — Panel + + + + + +
+ + + + +
+
+ +
{% block page_title %}{% endblock %}
+ {{ now.strftime('%d.%m.%Y %H:%M') }} +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat, msg in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + +
{% block content %}{% endblock %}
+
+
+ + + + +{% block scripts %}{% endblock %} + + diff --git a/web/templates/blocks.html b/web/templates/blocks.html new file mode 100644 index 0000000..343cb53 --- /dev/null +++ b/web/templates/blocks.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}Block-Events{% endblock %} +{% block page_title %}Block-Events{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
{{ total }} block events
+
+
+ + + + + + {% for r in rows %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
TimeTypePlayerBlockWorldPositionToolSilk
{{ r.timestamp | fmt_dt }} + {% set colors = {'break':'danger','place':'success','ignite':'warning','burn':'orange','explode':'dark'} %} + {{ r.event_type }} + {{ r.player_name or '—' }}{{ r.block_type }}{{ r.world }}{{ r.x }}, {{ r.y }}, {{ r.z }}{{ r.tool or '—' }}{% if r.is_silk %}{% else %}—{% endif %}
No block events
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/chat.html b/web/templates/chat.html new file mode 100644 index 0000000..51a5760 --- /dev/null +++ b/web/templates/chat.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}Chat Log{% endblock %} +{% block page_title %}Chat Log{% endblock %} + +{% block content %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Reset +
+
+ +
+
{{ total }} messages
+
+
+ + + + + + {% for r in rows %} + + + + + + + + {% else %} + + {% endfor %} + +
TimePlayerServerChannelMessage
{{ r.timestamp | fmt_dt }}{{ r.player_name or '—' }}{{ r.server_name or '—' }}{{ r.channel or 'global' }}{{ r.message }}
No messages found
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/commands.html b/web/templates/commands.html new file mode 100644 index 0000000..b7a88c7 --- /dev/null +++ b/web/templates/commands.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Commands{% endblock %} +{% block page_title %}Commands{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
{{ total }} commands
+
+
+ + + + + + {% for r in rows %} + + + + + + + + {% else %} + + {% endfor %} + +
TimePlayerServerCommandPosition
{{ r.timestamp | fmt_dt }}{{ r.player_name or '—' }}{{ r.server_name or '—' }}{{ r.command }} + {% if r.world %}{{ r.world }} ({{ r.x|round(0)|int }}, {{ r.y|round(0)|int }}, {{ r.z|round(0)|int }}){% else %}—{% endif %} +
No commands
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..7938519 --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,221 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} +{% block page_title %}Dashboard{% endblock %} + +{% block content %} + + +
+ {% set cards = [ + ('Total Players', stats.players_total, 'bi-people-fill', 'success'), + ('Sessions Today', stats.sessions_today, 'bi-clock-history', 'info'), + ('Chats Today', stats.chat_today, 'bi-chat-dots-fill', 'primary'), + ('Commands Today', stats.commands_today, 'bi-terminal-fill', 'warning'), + ('Blocks Today', stats.blocks_today, 'bi-bricks', 'secondary'), + ('Deaths Today', stats.deaths_today, 'bi-heartbreak-fill', 'danger'), + ('Entity Events', stats.entity_events_today, 'bi-bug-fill', 'light'), + ('Proxy Events', stats.proxy_events_today, 'bi-diagram-3-fill', 'dark'), + ] %} + {% for label, value, icon, color in cards %} +
+
+
+
+ +
+
+
{{ value | int }}
+
{{ label }}
+
+
+
+
+ {% endfor %} +
+ + +
+ +
+
+
+ Online Players + +
+
+
+ {% if online %} + + + + + + {% for s in online %} + + + + + + + {% endfor %} + +
PlayerServerCountrySince
{{ s.username }}{{ s.server_name }}{{ s.country or '—' }}{{ s.login_time | fmt_dt }}
+ {% else %} +
+
+ No players online +
+ {% endif %} +
+
+
+
+ + +
+
+
+ Last 24h Activity +
+
+ + + + + + {% for r in recent %} + + + + + + + + {% endfor %} + +
TimeTypePlayerServerDetail
{{ r.timestamp | fmt_dt }} + {% set badge = {'chat':'primary','command':'warning','block':'secondary','death':'danger'} %} + {{ r.source }} + {{ r.player_name or '—' }}{{ r.server_name or '—' }}{{ r.detail }}
+
+
+
+
+ + +
+ +
+
+
Block Events (last 7 days)
+
+ +
+
+
+ + +
+
+
Death Causes (7d)
+
+ +
+
+
+ + +
+
+
Top Playtime
+
+ + + {% for p in top_players %} + + + + + {% endfor %} + +
{{ loop.index }}. {{ p.username }}{{ p.total_playtime_sec | fmt_duration }}
+
+
+
+
+ + +
+
+
+
+ Server Events (last 24h) +
+
+ + + + {% for e in server_events %} + + + + + + + {% endfor %} + +
TimeTypeServerMessage
{{ e.timestamp | fmt_dt }}{{ e.event_type }}{{ e.server_name }}{{ e.message }}
+
+
+
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/deaths.html b/web/templates/deaths.html new file mode 100644 index 0000000..64710f7 --- /dev/null +++ b/web/templates/deaths.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Deaths{% endblock %} +{% block page_title %}Deaths{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
{{ total }} deaths
+
+
+ + + + + + {% for r in rows %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
TimePlayerCauseKillerKiller TypeLevelWorldDeath Message
{{ r.timestamp | fmt_dt }}{{ r.player_name }}{{ r.cause or '—' }}{{ r.killer_name or '—' }}{{ r.killer_type or '—' }}{{ r.exp_level }}{{ r.world }}{{ r.death_message or '—' }}
No deaths
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/group_admin/base.html b/web/templates/group_admin/base.html new file mode 100644 index 0000000..7d4c712 --- /dev/null +++ b/web/templates/group_admin/base.html @@ -0,0 +1,46 @@ + + + + + + {% block title %}Gruppen Admin{% endblock %} — {{ session.get('group_name','') }} + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for cat, msg in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ +{% block scripts %}{% endblock %} + + diff --git a/web/templates/group_admin/dashboard.html b/web/templates/group_admin/dashboard.html new file mode 100644 index 0000000..075d260 --- /dev/null +++ b/web/templates/group_admin/dashboard.html @@ -0,0 +1,77 @@ +{% extends "group_admin/base.html" %} +{% block title %}Dashboard{% endblock %} +{% block content %} +

Gruppenadmin: {{ session.get('group_name') }}

+ +
+
+
+
+
{{ stats.member_count }}
+
Mitglieder
+
+
+
+
+
+
+
+ {{ 'Ja' if stats.db_configured else 'Nein' }} +
+
DB konfiguriert
+
+
+
+
+
+
+
{{ stats.admin_count }}
+
Admins
+
+
+
+
+ +
+ + +
+
+
Gruppeninfo
+
+
+
Name
+
{{ session.get('group_name') }}
+
Deine Rolle
+
Admin
+
Datenbank
+
+ {% if stats.db_configured %} + Verbunden + {% else %} + Nicht konfiguriert + {% endif %} +
+
+
+
+
+
+{% endblock %} diff --git a/web/templates/group_admin/database.html b/web/templates/group_admin/database.html new file mode 100644 index 0000000..437ac2f --- /dev/null +++ b/web/templates/group_admin/database.html @@ -0,0 +1,98 @@ +{% extends "group_admin/base.html" %} +{% block title %}Datenbank{% endblock %} +{% block content %} +

MC Datenbank konfigurieren

+ +
+
+
+
Verbindungsdaten
+
+ {% if test_result is defined %} +
+ {% if test_result %} + Verbindung erfolgreich! Daten wurden gespeichert. + {% else %} + Verbindung fehlgeschlagen: {{ test_error }} + {% endif %} +
+ {% endif %} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + {% if creds %} +
Leer lassen um das bestehende Passwort beizubehalten.
+ {% endif %} +
+
+ +
+ + {% if creds %} + + {% endif %} +
+
+
+
+
+ +
+
+
Info
+
+

+ Gib hier die Verbindungsdaten zu deiner MCLogger MySQL-Datenbank ein. + Das Panel liest nur Daten (SELECT) — schreibender Zugriff ist nicht nötig. +

+

+ Die Zugangsdaten werden verschlüsselt gespeichert und sind nur für deine Gruppe sichtbar. +

+
+

Benötigte Tabellen:

+
    +
  • player_sessions
  • +
  • chat_messages
  • +
  • player_commands
  • +
  • block_events
  • +
  • player_deaths
  • +
  • proxy_events
  • +
  • server_events
  • +
  • permission_changes
  • +
+
+
+
+
+{% endblock %} diff --git a/web/templates/group_admin/member_edit.html b/web/templates/group_admin/member_edit.html new file mode 100644 index 0000000..a50b30c --- /dev/null +++ b/web/templates/group_admin/member_edit.html @@ -0,0 +1,54 @@ +{% extends "group_admin/base.html" %} +{% block title %}Berechtigungen – {{ member.username }}{% endblock %} +{% block content %} +
+ + + +

Berechtigungen: {{ member.username }}

+
+ +
+
+
+
+ Panel-Berechtigungen +
+
+
+
+ + +
Admins können Mitglieder und die DB-Verbindung verwalten.
+
+ +
+

Panel-Zugriff

+
+ {% for key, label in all_permissions %} +
+
+ + +
+
+ {% endfor %} +
+ +
+ + Abbrechen +
+
+
+
+
+
+{% endblock %} diff --git a/web/templates/group_admin/members.html b/web/templates/group_admin/members.html new file mode 100644 index 0000000..3e40e67 --- /dev/null +++ b/web/templates/group_admin/members.html @@ -0,0 +1,65 @@ +{% extends "group_admin/base.html" %} +{% block title %}Mitglieder{% endblock %} +{% block content %} +

Mitglieder

+ +
+ +
+
+
Aktuelle Mitglieder ({{ members|length }})
+
+ + + + {% for m in members %} + + + + + + {% else %} + + {% endfor %} + +
BenutzerRolleAktionen
{{ m.username }} + {% if m.role == 'admin' %} + Admin + {% else %} + Member + {% endif %} + + {% if m.id != session.get('user_id') %} + + + +
+ +
+ {% else %} + Du + {% endif %} +
Keine Mitglieder
+
+
+
+ + +
+
+
Hinweis
+
+

+ Neue Mitglieder müssen vom Site Admin zur Gruppe hinzugefügt werden. +

+

+ Als Gruppenadmin kannst du Berechtigungen bestehender Mitglieder verwalten und Mitglieder entfernen. +

+
+
+
+
+{% endblock %} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..782e13a --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,49 @@ + + + + + + MCLogger – Login + + + + + +
+
+
+ +

MCLogger

+

Admin-Interface · SimolZimol

+
+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+
+
+ + + diff --git a/web/templates/panel/blocks.html b/web/templates/panel/blocks.html new file mode 100644 index 0000000..a3688f2 --- /dev/null +++ b/web/templates/panel/blocks.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}Block-Events{% endblock %} +{% block page_title %}Block-Events{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
{{ total }} block events
+
+
+ + + + + + {% for r in rows %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
TimeTypePlayerBlockWorldPositionToolSilk
{{ r.timestamp | fmt_dt }} + {% set colors = {'break':'danger','place':'success','ignite':'warning','burn':'orange','explode':'dark'} %} + {{ r.event_type }} + {{ r.player_name or '—' }}{{ r.block_type }}{{ r.world }}{{ r.x }}, {{ r.y }}, {{ r.z }}{{ r.tool or '—' }}{% if r.is_silk %}{% else %}—{% endif %}
No block events
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/panel/chat.html b/web/templates/panel/chat.html new file mode 100644 index 0000000..1b9e7f4 --- /dev/null +++ b/web/templates/panel/chat.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}Chat Log{% endblock %} +{% block page_title %}Chat Log{% endblock %} + +{% block content %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Reset +
+
+ +
+
{{ total }} messages
+
+
+ + + + + + {% for r in rows %} + + + + + + + + {% else %} + + {% endfor %} + +
TimePlayerServerChannelMessage
{{ r.timestamp | fmt_dt }}{{ r.player_name or '—' }}{{ r.server_name or '—' }}{{ r.channel or 'global' }}{{ r.message }}
No messages found
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/panel/commands.html b/web/templates/panel/commands.html new file mode 100644 index 0000000..aeb1854 --- /dev/null +++ b/web/templates/panel/commands.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Commands{% endblock %} +{% block page_title %}Commands{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
{{ total }} commands
+
+
+ + + + + + {% for r in rows %} + + + + + + + + {% else %} + + {% endfor %} + +
TimePlayerServerCommandPosition
{{ r.timestamp | fmt_dt }}{{ r.player_name or '—' }}{{ r.server_name or '—' }}{{ r.command }} + {% if r.world %}{{ r.world }} ({{ r.x|round(0)|int }}, {{ r.y|round(0)|int }}, {{ r.z|round(0)|int }}){% else %}—{% endif %} +
No commands
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/panel/dashboard.html b/web/templates/panel/dashboard.html new file mode 100644 index 0000000..08e976d --- /dev/null +++ b/web/templates/panel/dashboard.html @@ -0,0 +1,194 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} +{% block page_title %}Dashboard{% endblock %} + +{% block content %} + +
+ {% set cards = [ + ('Total Players', stats.players_total, 'bi-people-fill', 'success'), + ('Sessions Today', stats.sessions_today, 'bi-clock-history', 'info'), + ('Chats Today', stats.chat_today, 'bi-chat-dots-fill', 'primary'), + ('Commands Today', stats.commands_today, 'bi-terminal-fill', 'warning'), + ('Blocks Today', stats.blocks_today, 'bi-bricks', 'secondary'), + ('Deaths Today', stats.deaths_today, 'bi-heartbreak-fill', 'danger'), + ('Entity Events', stats.entity_events_today, 'bi-bug-fill', 'light'), + ('Proxy Events', stats.proxy_events_today, 'bi-diagram-3-fill', 'dark'), + ] %} + {% for label, value, icon, color in cards %} +
+
+
+
+ +
+
+
{{ value | int }}
+
{{ label }}
+
+
+
+
+ {% endfor %} +
+ + +
+
+
+
+ Online Players + +
+
+
+ {% if online %} + + + + + + {% for s in online %} + + + + + + {% endfor %} + +
PlayerServerSince
{{ s.player_name }}{{ s.server_name }}{{ s.login_time | fmt_dt }}
+ {% else %} +
+
No players online +
+ {% endif %} +
+
+
+
+ +
+
+
+ Last 24h Activity +
+
+ + + + + + {% for r in recent %} + + + + + + + + {% endfor %} + +
TimeTypePlayerServerDetail
{{ r.timestamp | fmt_dt }} + {% set badge = {'chat':'primary','command':'warning','block':'secondary','death':'danger'} %} + {{ r.source }} + {{ r.player_name or '—' }}{{ r.server_name or '—' }}{{ r.detail }}
+
+
+
+
+ + +
+
+
+
Block Events (last 7 days)
+
+ +
+
+
+
+
+
Death Causes (7d)
+
+ +
+
+
+
+
+
Top Playtime
+
+ + + {% for p in top_players %} + + + + + {% endfor %} + +
{{ loop.index }}. {{ p.username }}{{ p.total_playtime_sec | fmt_duration }}
+
+
+
+
+ + +
+
+
+
Server Events (last 24h)
+
+ + + + {% for e in server_events %} + + + + + + + {% endfor %} + +
TimeTypeServerMessage
{{ e.timestamp | fmt_dt }}{{ e.event_type }}{{ e.server_name }}{{ e.message }}
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/panel/deaths.html b/web/templates/panel/deaths.html new file mode 100644 index 0000000..4cfec03 --- /dev/null +++ b/web/templates/panel/deaths.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Deaths{% endblock %} +{% block page_title %}Deaths{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
{{ total }} deaths
+
+
+ + + + + + {% for r in rows %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
TimePlayerCauseKillerKiller TypeLevelWorldDeath Message
{{ r.timestamp | fmt_dt }}{{ r.player_name }}{{ r.cause or '—' }}{{ r.killer_name or '—' }}{{ r.killer_type or '—' }}{{ r.exp_level }}{{ r.world }}{{ r.death_message or '—' }}
No deaths
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/panel/no_db.html b/web/templates/panel/no_db.html new file mode 100644 index 0000000..594b00a --- /dev/null +++ b/web/templates/panel/no_db.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}No Database{% endblock %} +{% block page_title %}Keine Datenbank{% endblock %} +{% block content %} +
+
+ +

Keine Datenbank konfiguriert

+

+ Für diese Gruppe ist noch keine MC-Datenbank eingerichtet. + {% if session.get('role') == 'admin' %} + Du kannst die Verbindung als Gruppen-Admin konfigurieren. + {% else %} + Bitte wende dich an deinen Gruppenadmin. + {% endif %} +

+ {% if session.get('role') == 'admin' %} + + Datenbank konfigurieren + + {% endif %} +
+
+{% endblock %} diff --git a/web/templates/panel/perms.html b/web/templates/panel/perms.html new file mode 100644 index 0000000..1c7fa18 --- /dev/null +++ b/web/templates/panel/perms.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Permissions{% endblock %} +{% block page_title %}Permissions{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ +
+
+ + Reset +
+
+ +
+
{{ total }} permission events
+
+
+ + + + + + {% for r in rows %} + {% set badge_colors = { + 'luckperms_permission_set': 'success', + 'luckperms_permission_unset': 'danger', + 'luckperms_parent_add': 'primary', + 'luckperms_parent_remove': 'warning', + 'luckperms_meta_set': 'info', + 'luckperms_meta_unset': 'secondary', + 'luckperms_group_create': 'light', + 'luckperms_group_delete': 'dark', + } %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
TimePluginEvent TypeTarget PlayerActorTarget TypeTarget IDActionServer
{{ r.timestamp | fmt_dt }}{{ r.plugin_name or '—' }}{{ r.event_type }}{{ r.player_name or '—' }}{{ r.actor_name or '—' }}{{ r.target_type or '—' }}{{ r.target_id or '—' }}{{ r.action or '—' }}{{ r.server_name or '—' }}
No permission events found
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/panel/player_detail.html b/web/templates/panel/player_detail.html new file mode 100644 index 0000000..9aed45a --- /dev/null +++ b/web/templates/panel/player_detail.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} +{% block title %}{{ player.username }}{% endblock %} +{% block page_title %}{{ player.username }}{% endblock %} + +{% block content %} +
+
+
+
+ {{ player.username }} +
{{ player.username }}
+ {% if player.is_op %} + OP + {% endif %} + + + + + + + +
UUID{{ player.uuid }}
IP{{ player.ip_address or '—' }}
Locale{{ player.locale or '—' }}
Playtime{{ player.total_playtime_sec | fmt_duration }}
Since{{ player.first_seen | fmt_dt }}
Last Seen{{ player.last_seen | fmt_dt }}
+
+
+
+ +
+ + +
+
+
+ + + + {% for s in sessions %} + + + + + + {% else %}{% endfor %} + +
LoginLogoutDurationServerIP
{{ s.login_time | fmt_dt }}{{ s.logout_time | fmt_dt }}{{ s.duration_sec | fmt_duration }}{{ s.server_name or '—' }}{{ s.ip_address or '—' }}
No sessions
+
+
+ +
+
+ + + + {% for c in chat %} + + + + {% else %}{% endfor %} + +
TimeServerMessage
{{ c.timestamp | fmt_dt }}{{ c.server_name or '—' }}{{ c.message }}
No chat messages
+
+
+ +
+
+ + + + {% for c in commands %} + + + + + {% else %}{% endfor %} + +
TimeServerCommandPosition
{{ c.timestamp | fmt_dt }}{{ c.server_name or '—' }}{{ c.command }}{{ c.world or '' }} {% if c.x %}({{ c.x|round(1) }}, {{ c.y|round(1) }}, {{ c.z|round(1) }}){% endif %}
No commands
+
+
+ +
+
+ + + + {% for d in deaths %} + + + + + + {% else %}{% endfor %} + +
TimeCauseKillerLevelWorld
{{ d.timestamp | fmt_dt }}{{ d.cause or '—' }}{{ d.killer_name or '—' }}{{ d.exp_level }}{{ d.world }}
No deaths
+
+
+ +
+
+ + + + {% for t in teleports %} + + + + + {% else %}{% endfor %} + +
TimeFromToCause
{{ t.timestamp | fmt_dt }}{{ t.from_world }} ({{ t.from_x|round(0)|int }}, {{ t.from_y|round(0)|int }}, {{ t.from_z|round(0)|int }}){{ t.to_world }} ({{ t.to_x|round(0)|int }}, {{ t.to_y|round(0)|int }}, {{ t.to_z|round(0)|int }}){{ t.cause or '—' }}
No teleports
+
+
+ +
+
+ + + + {% for e in proxy_events %} + + + + + {% else %}{% endfor %} + +
TimeTypeFromTo
{{ e.timestamp | fmt_dt }}{{ e.event_type }}{{ e.from_server or '—' }}{{ e.to_server or '—' }}
No proxy events
+
+
+
+
+
+ + + Back to Overview + +{% endblock %} diff --git a/web/templates/panel/players.html b/web/templates/panel/players.html new file mode 100644 index 0000000..abad74c --- /dev/null +++ b/web/templates/panel/players.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}Players{% endblock %} +{% block page_title %}Players{% endblock %} + +{% block content %} +
+
+ +
+
+ + {% if search %}Reset{% endif %} +
+
+ +
+
+ {{ total }} players found +
+
+
+ + + + + + {% for p in players %} + + + + + + + + + + {% else %} + + {% endfor %} + +
PlayerIPFirst SeenLast SeenPlaytimeOP
+ {{ p.username }} + {{ p.ip_address or '—' }}{{ p.first_seen | fmt_dt }}{{ p.last_seen | fmt_dt }}{{ p.total_playtime_sec | fmt_duration }} + {% if p.is_op %} + OP + {% else %}{% endif %} + + + + +
No players found
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/panel/proxy.html b/web/templates/panel/proxy.html new file mode 100644 index 0000000..67ef5a7 --- /dev/null +++ b/web/templates/panel/proxy.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Proxy Events{% endblock %} +{% block page_title %}Proxy Events{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
{{ total }} proxy events
+
+
+ + + + + + {% for r in rows %} + {% set badge = {'login':'success','disconnect':'danger','server_switch':'primary','command':'warning','proxy_start':'info','proxy_stop':'dark'} %} + + + + + + + + + + {% else %} + + {% endfor %} + +
TimeTypePlayerProxyFromToIP
{{ r.timestamp | fmt_dt }}{{ r.event_type }}{{ r.player_name or '—' }}{{ r.proxy_name or '—' }}{{ r.from_server or '—' }}{{ r.to_server or '—' }}{{ r.ip_address or '—' }}
No proxy events
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/panel/server_events.html b/web/templates/panel/server_events.html new file mode 100644 index 0000000..ae451f9 --- /dev/null +++ b/web/templates/panel/server_events.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Server Events{% endblock %} +{% block page_title %}Server Events{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
{{ total }} server events
+
+
+ + + + + + {% for r in rows %} + {% set badge = {'server_start':'success','server_stop':'danger','player_join':'info','player_quit':'secondary','player_kick':'warning'} %} + + + + + + + {% else %} + + {% endfor %} + +
TimeTypeServerMessage
{{ r.timestamp | fmt_dt }}{{ r.event_type }}{{ r.server_name or '—' }}{{ r.message or '—' }}
No events
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/panel/sessions.html b/web/templates/panel/sessions.html new file mode 100644 index 0000000..a12c246 --- /dev/null +++ b/web/templates/panel/sessions.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}Sessions{% endblock %} +{% block page_title %}Sessions{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
{{ total }} sessions
+
+
+ + + + + + {% for r in rows %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
PlayerServerLoginLogoutDurationIPCountryClient
+ + {{ r.player_name }} + + {{ r.server_name or '—' }}{{ r.login_time | fmt_dt }}{{ r.logout_time | fmt_dt }} + {% if r.logout_time %}{{ r.duration_sec | fmt_duration }} + {% else %}Online{% endif %} + {{ r.ip_address or '—' }}{{ r.country or '—' }}{{ r.client_version or '—' }}
No sessions
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/perms.html b/web/templates/perms.html new file mode 100644 index 0000000..ea65d44 --- /dev/null +++ b/web/templates/perms.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}Permissions{% endblock %} +{% block page_title %}Permissions{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ +
+
+ + Reset +
+
+ +
+
{{ total }} permission events
+
+
+ + + + + + + + + + + + + + + + {% for r in rows %} + {% set badge_colors = { + 'luckperms_permission_set': 'success', + 'luckperms_permission_unset': 'danger', + 'luckperms_parent_add': 'primary', + 'luckperms_parent_remove': 'warning', + 'luckperms_meta_set': 'info', + 'luckperms_meta_unset': 'secondary', + 'luckperms_group_create': 'light', + 'luckperms_group_delete': 'dark', + } %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
TimePluginEvent TypeTarget PlayerActorTarget TypeTarget IDActionServer
{{ r.timestamp | fmt_dt }}{{ r.plugin_name or '—' }}{{ r.event_type }}{{ r.player_name or '—' }}{{ r.actor_name or '—' }}{{ r.target_type or '—' }}{{ r.target_id or '—' }}{{ r.action or '—' }}{{ r.server_name or '—' }}
No permission events found
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/player_detail.html b/web/templates/player_detail.html new file mode 100644 index 0000000..872e735 --- /dev/null +++ b/web/templates/player_detail.html @@ -0,0 +1,182 @@ +{% extends "base.html" %} +{% block title %}{{ player.username }}{% endblock %} +{% block page_title %}{{ player.username }}{% endblock %} + +{% block content %} + +
+
+
+
+ {{ player.username }} +
{{ player.username }}
+ {% if player.is_op %} + OP + {% endif %} + + + + + + + +
UUID{{ player.uuid }}
IP{{ player.ip_address or '—' }}
Locale{{ player.locale or '—' }}
Playtime{{ player.total_playtime_sec | fmt_duration }}
Since{{ player.first_seen | fmt_dt }}
Last Seen{{ player.last_seen | fmt_dt }}
+
+
+
+ +
+ + + +
+ +
+
+ + + + {% for s in sessions %} + + + + + + + + {% else %}{% endfor %} + +
LoginLogoutDurationServerIP
{{ s.login_time | fmt_dt }}{{ s.logout_time | fmt_dt }}{{ s.duration_sec | fmt_duration }}{{ s.server_name or '—' }}{{ s.ip_address or '—' }}
No sessions
+
+
+ + +
+
+ + + + {% for c in chat %} + + + + + + {% else %}{% endfor %} + +
TimeServerMessage
{{ c.timestamp | fmt_dt }}{{ c.server_name or '—' }}{{ c.message }}
No chat messages
+
+
+ + +
+
+ + + + {% for c in commands %} + + + + + + + {% else %}{% endfor %} + +
TimeServerCommandPosition
{{ c.timestamp | fmt_dt }}{{ c.server_name or '—' }}{{ c.command }}{{ c.world or '' }} {% if c.x %}({{ c.x|round(1) }}, {{ c.y|round(1) }}, {{ c.z|round(1) }}){% endif %}
No commands
+
+
+ + +
+
+ + + + {% for d in deaths %} + + + + + + + + {% else %}{% endfor %} + +
TimeCauseKillerLevelWorld
{{ d.timestamp | fmt_dt }}{{ d.cause or '—' }}{{ d.killer_name or '—' }}{{ d.exp_level }}{{ d.world }}
No deaths
+
+
+ + +
+
+ + + + {% for t in teleports %} + + + + + + + {% else %}{% endfor %} + +
TimeFromToCause
{{ t.timestamp | fmt_dt }}{{ t.from_world }} ({{ t.from_x|round(0)|int }}, {{ t.from_y|round(0)|int }}, {{ t.from_z|round(0)|int }}){{ t.to_world }} ({{ t.to_x|round(0)|int }}, {{ t.to_y|round(0)|int }}, {{ t.to_z|round(0)|int }}){{ t.cause or '—' }}
No teleports
+
+
+ + +
+
+ + + + {% for s in stats %} + + + + + + + {% else %}{% endfor %} + +
TimeTypeOldNew
{{ s.timestamp | fmt_dt }}{{ s.event_type }}{{ s.old_value or '—' }}{{ s.new_value or '—' }}
No stats
+
+
+ + +
+
+ + + + {% for e in proxy_events %} + + + + + + + {% else %}{% endfor %} + +
TimeTypeFromTo
{{ e.timestamp | fmt_dt }}{{ e.event_type }}{{ e.from_server or '—' }}{{ e.to_server or '—' }}
No proxy events
+
+
+
+
+
+ + + Back to Overview + +{% endblock %} diff --git a/web/templates/players.html b/web/templates/players.html new file mode 100644 index 0000000..71c51b4 --- /dev/null +++ b/web/templates/players.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block title %}Players{% endblock %} +{% block page_title %}Players{% endblock %} + +{% block content %} + +
+
+ +
+
+ + {% if search %}Reset{% endif %} +
+
+ +
+
+ {{ total }} players found +
+
+
+ + + + + + + + + {% for p in players %} + + + + + + + + + + {% else %} + + {% endfor %} + +
PlayerIPFirst SeenLast SeenPlaytimeOP
+ {{ p.username }} + {{ p.ip_address or '—' }}{{ p.first_seen | fmt_dt }}{{ p.last_seen | fmt_dt }}{{ p.total_playtime_sec | fmt_duration }} + {% if p.is_op %} + OP + {% else %}{% endif %} + + + + +
No players found
+
+
+
+ + +{% if pages > 1 %} + +{% endif %} +{% endblock %} diff --git a/web/templates/proxy.html b/web/templates/proxy.html new file mode 100644 index 0000000..7b96266 --- /dev/null +++ b/web/templates/proxy.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Proxy Events{% endblock %} +{% block page_title %}Proxy Events{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
{{ total }} proxy events
+
+
+ + + + + + {% for r in rows %} + {% set badge = {'login':'success','disconnect':'danger','server_switch':'primary','command':'warning','proxy_start':'info','proxy_stop':'dark'} %} + + + + + + + + + + {% else %} + + {% endfor %} + +
TimeTypePlayerProxyFromToIP
{{ r.timestamp | fmt_dt }}{{ r.event_type }}{{ r.player_name or '—' }}{{ r.proxy_name or '—' }}{{ r.from_server or '—' }}{{ r.to_server or '—' }}{{ r.ip_address or '—' }}
No proxy events
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/server_events.html b/web/templates/server_events.html new file mode 100644 index 0000000..ecba039 --- /dev/null +++ b/web/templates/server_events.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Server Events{% endblock %} +{% block page_title %}Server Events{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
{{ total }} server events
+
+
+ + + + + + {% for r in rows %} + {% set badge = {'server_start':'success','server_stop':'danger','player_join':'info','player_quit':'secondary','player_kick':'warning'} %} + + + + + + + {% else %} + + {% endfor %} + +
TimeTypeServerMessage
{{ r.timestamp | fmt_dt }}{{ r.event_type }}{{ r.server_name or '—' }}{{ r.message or '—' }}
No events
+
+
+
+{% include "_pagination.html" %} +{% endblock %} diff --git a/web/templates/sessions.html b/web/templates/sessions.html new file mode 100644 index 0000000..f34fb85 --- /dev/null +++ b/web/templates/sessions.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}Sessions{% endblock %} +{% block page_title %}Sessions{% endblock %} +{% block content %} +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
{{ total }} sessions
+
+
+ + + + + + {% for r in rows %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
PlayerServerLoginLogoutDurationIPCountryClient
+ + {{ r.player_name }} + + {{ r.server_name or '—' }}{{ r.login_time | fmt_dt }}{{ r.logout_time | fmt_dt }} + {% if r.logout_time %}{{ r.duration_sec | fmt_duration }} + {% else %}Online{% endif %} + {{ r.ip_address or '—' }}{{ r.country or '—' }}{{ r.client_version or '—' }}
No sessions
+
+
+
+{% include "_pagination.html" %} +{% endblock %}