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
This commit is contained in:
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal file
@@ -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
|
||||
280
README.md
Normal file
280
README.md
Normal file
@@ -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.
|
||||
360
database/schema.sql
Normal file
360
database/schema.sql
Normal file
@@ -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;
|
||||
113
paper-plugin/pom.xml
Normal file
113
paper-plugin/pom.xml
Normal file
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>de.simolzimol</groupId>
|
||||
<artifactId>mclogger-paper</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>MCLogger-Paper</name>
|
||||
<description>Comprehensive Minecraft event logger</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>papermc</id>
|
||||
<url>https://repo.papermc.io/repository/maven-public/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>lucko</id>
|
||||
<url>https://repo.lucko.me/</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<dependencies>
|
||||
<!-- Paper API 1.21 -->
|
||||
<dependency>
|
||||
<groupId>io.papermc.paper</groupId>
|
||||
<artifactId>paper-api</artifactId>
|
||||
<version>1.21-R0.1-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- HikariCP – Connection Pooling -->
|
||||
<dependency>
|
||||
<groupId>com.zaxxer</groupId>
|
||||
<artifactId>HikariCP</artifactId>
|
||||
<version>5.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MariaDB JDBC Driver -->
|
||||
<dependency>
|
||||
<groupId>org.mariadb.jdbc</groupId>
|
||||
<artifactId>mariadb-java-client</artifactId>
|
||||
<version>3.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Gson für JSON-Serialisierung -->
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.11.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- LuckPerms API (optional / soft-depend) -->
|
||||
<dependency>
|
||||
<groupId>net.luckperms</groupId>
|
||||
<artifactId>api</artifactId>
|
||||
<version>5.4</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>${project.artifactId}-${project.version}</finalName>
|
||||
<plugins>
|
||||
<!-- Shade: alle Abhängigkeiten in eine JAR -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals><goal>shade</goal></goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<relocations>
|
||||
<relocation>
|
||||
<pattern>com.zaxxer.hikari</pattern>
|
||||
<shadedPattern>de.simolzimol.mclogger.lib.hikari</shadedPattern>
|
||||
</relocation>
|
||||
<relocation>
|
||||
<pattern>org.mariadb</pattern>
|
||||
<shadedPattern>de.simolzimol.mclogger.lib.mariadb</shadedPattern>
|
||||
</relocation>
|
||||
</relocations>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/LICENSE*</exclude>
|
||||
<exclude>META-INF/NOTICE*</exclude>
|
||||
<exclude>META-INF/DEPENDENCIES</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -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<String, Object> 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<String, Object> 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<LuckPerms> 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; }
|
||||
}
|
||||
@@ -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 <player> [page] – Chat log (mclogger.view.chat)
|
||||
* /mclogger commands <player> [page] – Command log (mclogger.view.commands)
|
||||
* /mclogger deaths <player> [page] – Death log (mclogger.view.deaths)
|
||||
* /mclogger sessions <player> [page] – Session log (mclogger.view.sessions)
|
||||
* /mclogger blocks <x> <y> <z> [page]– Block log (mclogger.view.blocks)
|
||||
* /mclogger perms player <name> [page] – Perms by target player (mclogger.view.perms)
|
||||
* /mclogger perms actor <name> [page] – Perms by actor (mclogger.view.perms)
|
||||
* /mclogger perms group <name> [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<String> 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<String> 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 <player> [page]", "Chat history");
|
||||
if (s.hasPermission("mclogger.view.commands"))
|
||||
sendLine(s, "/mcl commands <player> [page]", "Command history");
|
||||
if (s.hasPermission("mclogger.view.deaths"))
|
||||
sendLine(s, "/mcl deaths <player> [page]", "Death history");
|
||||
if (s.hasPermission("mclogger.view.sessions"))
|
||||
sendLine(s, "/mcl sessions <player> [page]", "Session history");
|
||||
if (s.hasPermission("mclogger.view.blocks"))
|
||||
sendLine(s, "/mcl blocks <x> <y> <z> [page]","Block log at position");
|
||||
if (s.hasPermission("mclogger.view.perms"))
|
||||
sendLine(s, "/mcl perms <player> [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 <player> [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 <player> [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 <player> [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 <player> [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 <x> <y> <z> [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 <player|actor|group|all> [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 <name> [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 <name> [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 <name> [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 <player|actor|group|all> [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<String> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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(); }
|
||||
}
|
||||
@@ -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<String, Object> 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> buildItemMeta(ItemStack item) {
|
||||
if (!item.hasItemMeta()) return null;
|
||||
ItemMeta meta = item.getItemMeta();
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
if (meta.hasDisplayName() && meta.displayName() != null) {
|
||||
m.put("name", PlainTextComponentSerializer.plainText().serialize(meta.displayName()));
|
||||
}
|
||||
if (meta.hasEnchants()) {
|
||||
Map<String, Integer> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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";
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> itemsLost = new LinkedHashMap<>();
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
for (ItemStack item : event.getDrops()) {
|
||||
if (item == null) continue;
|
||||
Map<String, Object> 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<String, Object> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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
|
||||
}
|
||||
}
|
||||
@@ -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<UUID, Long> 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<String, Object> 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<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
28
paper-plugin/src/main/resources/config.yml
Normal file
28
paper-plugin/src/main/resources/config.yml
Normal file
@@ -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
|
||||
59
paper-plugin/src/main/resources/plugin.yml
Normal file
59
paper-plugin/src/main/resources/plugin.yml
Normal file
@@ -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 <help|reload|status|chat|commands|deaths|sessions|blocks|perms>
|
||||
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
|
||||
28
paper-plugin/target/classes/config.yml
Normal file
28
paper-plugin/target/classes/config.yml
Normal file
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
59
paper-plugin/target/classes/plugin.yml
Normal file
59
paper-plugin/target/classes/plugin.yml
Normal file
@@ -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 <help|reload|status|chat|commands|deaths|sessions|blocks|perms>
|
||||
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
|
||||
3
paper-plugin/target/maven-archiver/pom.properties
Normal file
3
paper-plugin/target/maven-archiver/pom.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
artifactId=mclogger-paper
|
||||
groupId=de.simolzimol
|
||||
version=1.0.0
|
||||
@@ -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
|
||||
@@ -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
|
||||
BIN
paper-plugin/target/mclogger-paper-1.0.0.jar
Normal file
BIN
paper-plugin/target/mclogger-paper-1.0.0.jar
Normal file
Binary file not shown.
BIN
paper-plugin/target/original-mclogger-paper-1.0.0.jar
Normal file
BIN
paper-plugin/target/original-mclogger-paper-1.0.0.jar
Normal file
Binary file not shown.
117
velocity-plugin/pom.xml
Normal file
117
velocity-plugin/pom.xml
Normal file
@@ -0,0 +1,117 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>de.simolzimol</groupId>
|
||||
<artifactId>mclogger-velocity</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>MCLogger-Velocity</name>
|
||||
<description>Comprehensive Minecraft proxy event logger for Velocity with MariaDB storage</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>papermc</id>
|
||||
<url>https://repo.papermc.io/repository/maven-public/</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<dependencies>
|
||||
<!-- Velocity API -->
|
||||
<dependency>
|
||||
<groupId>com.velocitypowered</groupId>
|
||||
<artifactId>velocity-api</artifactId>
|
||||
<version>3.3.0-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- HikariCP – Connection Pooling -->
|
||||
<dependency>
|
||||
<groupId>com.zaxxer</groupId>
|
||||
<artifactId>HikariCP</artifactId>
|
||||
<version>5.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MariaDB JDBC Driver -->
|
||||
<dependency>
|
||||
<groupId>org.mariadb.jdbc</groupId>
|
||||
<artifactId>mariadb-java-client</artifactId>
|
||||
<version>3.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Gson -->
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.11.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>${project.artifactId}-${project.version}</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals><goal>shade</goal></goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<relocations>
|
||||
<relocation>
|
||||
<pattern>com.zaxxer.hikari</pattern>
|
||||
<shadedPattern>de.simolzimol.mclogger.velocity.lib.hikari</shadedPattern>
|
||||
</relocation>
|
||||
<relocation>
|
||||
<pattern>org.mariadb</pattern>
|
||||
<shadedPattern>de.simolzimol.mclogger.velocity.lib.mariadb</shadedPattern>
|
||||
</relocation>
|
||||
</relocations>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/LICENSE*</exclude>
|
||||
<exclude>META-INF/NOTICE*</exclude>
|
||||
<exclude>META-INF/DEPENDENCIES</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<!-- Annotation Processor für Velocity -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<source>17</source>
|
||||
<target>17</target>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>com.velocitypowered</groupId>
|
||||
<artifactId>velocity-api</artifactId>
|
||||
<version>3.3.0-SNAPSHOT</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -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<String, Object> 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<String, Object> 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; }
|
||||
}
|
||||
@@ -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<String, Object> 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(); }
|
||||
}
|
||||
@@ -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<UUID, Long> loginTimes = new ConcurrentHashMap<>();
|
||||
/** Commands already logged by a Paper backend – Velocity skips these */
|
||||
private final Set<String> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
17
velocity-plugin/src/main/resources/velocity-config.yml
Normal file
17
velocity-plugin/src/main/resources/velocity-config.yml
Normal file
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
17
velocity-plugin/target/classes/velocity-config.yml
Normal file
17
velocity-plugin/target/classes/velocity-config.yml
Normal file
@@ -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
|
||||
1
velocity-plugin/target/classes/velocity-plugin.json
Normal file
1
velocity-plugin/target/classes/velocity-plugin.json
Normal file
@@ -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"}
|
||||
3
velocity-plugin/target/maven-archiver/pom.properties
Normal file
3
velocity-plugin/target/maven-archiver/pom.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
artifactId=mclogger-velocity
|
||||
groupId=de.simolzimol
|
||||
version=1.0.0
|
||||
@@ -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
|
||||
@@ -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
|
||||
BIN
velocity-plugin/target/mclogger-velocity-1.0.0.jar
Normal file
BIN
velocity-plugin/target/mclogger-velocity-1.0.0.jar
Normal file
Binary file not shown.
BIN
velocity-plugin/target/original-mclogger-velocity-1.0.0.jar
Normal file
BIN
velocity-plugin/target/original-mclogger-velocity-1.0.0.jar
Normal file
Binary file not shown.
24
web/Dockerfile
Normal file
24
web/Dockerfile
Normal file
@@ -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"]
|
||||
|
||||
76
web/app.py
Normal file
76
web/app.py
Normal file
@@ -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)
|
||||
|
||||
1
web/blueprints/__init__.py
Normal file
1
web/blueprints/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# blueprints/__init__.py
|
||||
93
web/blueprints/auth.py
Normal file
93
web/blueprints/auth.py
Normal file
@@ -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/<int:group_id>")
|
||||
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
|
||||
164
web/blueprints/group_admin.py
Normal file
164
web/blueprints/group_admin.py
Normal file
@@ -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/<int:user_id>/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/<int:user_id>/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"))
|
||||
410
web/blueprints/panel.py
Normal file
410
web/blueprints/panel.py
Normal file
@@ -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/<uuid>")
|
||||
@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"],
|
||||
})
|
||||
221
web/blueprints/site_admin.py
Normal file
221
web/blueprints/site_admin.py
Normal file
@@ -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/<int:group_id>/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/<int:group_id>/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/<int:group_id>/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/<int:group_id>/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/<int:group_id>/members/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:group_id>")
|
||||
@admin_required
|
||||
def view_group(group_id):
|
||||
"""Site-Admin wechselt temporär in eine Gruppe, 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"))
|
||||
46
web/config.py
Normal file
46
web/config.py
Normal file
@@ -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,
|
||||
}
|
||||
63
web/crypto.py
Normal file
63
web/crypto.py
Normal file
@@ -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")
|
||||
46
web/docker-compose.yml
Normal file
46
web/docker-compose.yml
Normal file
@@ -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
|
||||
349
web/panel_db.py
Normal file
349
web/panel_db.py
Normal file
@@ -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
|
||||
4
web/requirements.txt
Normal file
4
web/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Flask==3.1.0
|
||||
PyMySQL==1.1.1
|
||||
cryptography==42.0.8
|
||||
gunicorn==22.0.0
|
||||
230
web/static/css/style.css
Normal file
230
web/static/css/style.css
Normal file
@@ -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; }
|
||||
97
web/static/js/main.js
Normal file
97
web/static/js/main.js
Normal file
@@ -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 = '<i class="bi bi-check2"></i>';
|
||||
setTimeout(() => btn.innerHTML = '<i class="bi bi-clipboard"></i>', 1500);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
22
web/templates/_pagination.html
Normal file
22
web/templates/_pagination.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% if pages > 1 %}
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination justify-content-center flex-wrap">
|
||||
<li class="page-item {{ 'disabled' if page == 1 }}">
|
||||
<a class="page-link" href="{{ url_for(request.endpoint, **dict(request.args, page=[page-1, 1]|max)) }}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% for p in range([1, page-3]|max, [pages+1, page+4]|min) %}
|
||||
<li class="page-item {{ 'active' if p == page }}">
|
||||
<a class="page-link" href="{{ url_for(request.endpoint, **dict(request.args, page=p)) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="page-item {{ 'disabled' if page == pages }}">
|
||||
<a class="page-link" href="{{ url_for(request.endpoint, **dict(request.args, page=[page+1, pages]|min)) }}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted small">Page {{ page }} of {{ pages }} · {{ total }} entries</p>
|
||||
</nav>
|
||||
{% endif %}
|
||||
43
web/templates/admin/base.html
Normal file
43
web/templates/admin/base.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Site Admin{% endblock %} — MCLogger</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-dark bg-danger">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand fw-bold" href="{{ url_for('site_admin.dashboard') }}">
|
||||
<i class="bi bi-shield-fill-check me-2"></i>MCLogger — Site Admin
|
||||
</a>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<a href="{{ url_for('site_admin.dashboard') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.dashboard' }}">Dashboard</a>
|
||||
<a href="{{ url_for('site_admin.groups') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.groups' }}">Gruppen</a>
|
||||
<a href="{{ url_for('site_admin.users') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.users' }}">Benutzer</a>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
|
||||
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
121
web/templates/admin/dashboard.html
Normal file
121
web/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,121 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-shield-fill-check text-danger me-2"></i>Site Admin Dashboard</h2>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold text-danger">{{ stats.group_count }}</div>
|
||||
<div class="text-muted">Gruppen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold text-warning">{{ stats.user_count }}</div>
|
||||
<div class="text-muted">Benutzer</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold text-success">{{ stats.db_configured }}</div>
|
||||
<div class="text-muted">DBs konfiguriert</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold text-info">{{ stats.admin_count }}</div>
|
||||
<div class="text-muted">Site Admins</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-collection-fill me-2"></i>Gruppen</span>
|
||||
<a href="{{ url_for('site_admin.group_create') }}" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-plus-lg"></i> Neu
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Name</th><th>Mitglieder</th><th>DB</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for g in groups %}
|
||||
<tr>
|
||||
<td>{{ g.name }}</td>
|
||||
<td>{{ g.member_count }}</td>
|
||||
<td>
|
||||
{% if g.has_db %}
|
||||
<span class="badge bg-success">Konfiguriert</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Keine</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('site_admin.view_group', group_id=g.id) }}" class="btn btn-sm btn-outline-info" title="Browse">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('site_admin.group_edit', group_id=g.id) }}" class="btn btn-sm btn-outline-secondary" title="Bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="text-muted text-center py-3">Keine Gruppen vorhanden</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a href="{{ url_for('site_admin.groups') }}" class="text-muted small">Alle Gruppen →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-people-fill me-2"></i>Benutzer</span>
|
||||
<a href="{{ url_for('site_admin.user_create') }}" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-plus-lg"></i> Neu
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Benutzer</th><th>Gruppen</th><th>Admin</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.group_count }}</td>
|
||||
<td>{% if u.is_site_admin %}<span class="badge bg-danger"><i class="bi bi-shield-fill"></i></span>{% endif %}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('site_admin.user_edit', user_id=u.id) }}" class="btn btn-sm btn-outline-secondary" title="Bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="text-muted text-center py-3">Keine Benutzer vorhanden</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a href="{{ url_for('site_admin.users') }}" class="text-muted small">Alle Benutzer →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
36
web/templates/admin/group_edit.html
Normal file
36
web/templates/admin/group_edit.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}{{ 'Gruppe bearbeiten' if group else 'Neue Gruppe' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center gap-2 mb-4">
|
||||
<a href="{{ url_for('site_admin.groups') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h2 class="mb-0">{{ 'Gruppe bearbeiten' if group else 'Neue Gruppe erstellen' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Gruppenname *</label>
|
||||
<input type="text" name="name" class="form-control" required
|
||||
value="{{ group.name if group else request.form.get('name', '') }}">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Beschreibung</label>
|
||||
<textarea name="description" class="form-control" rows="3">{{ group.description if group else request.form.get('description', '') }}</textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>{{ 'Speichern' if group else 'Erstellen' }}
|
||||
</button>
|
||||
<a href="{{ url_for('site_admin.groups') }}" class="btn btn-outline-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
86
web/templates/admin/group_members.html
Normal file
86
web/templates/admin/group_members.html
Normal file
@@ -0,0 +1,86 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Mitglieder – {{ group.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center gap-2 mb-4">
|
||||
<a href="{{ url_for('site_admin.groups') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h2 class="mb-0">Mitglieder: <span class="text-success">{{ group.name }}</span></h2>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Aktuelle Mitglieder -->
|
||||
<div class="col-md-7">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header"><i class="bi bi-people-fill me-2"></i>Aktuelle Mitglieder ({{ members|length }})</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Benutzer</th><th>Rolle</th><th class="text-end">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in members %}
|
||||
<tr>
|
||||
<td>{{ m.username }}</td>
|
||||
<td>
|
||||
{% if m.role == 'admin' %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Member</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<form method="post" action="{{ url_for('site_admin.group_member_toggle_role', group_id=group.id, user_id=m.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning" title="Rolle wechseln">
|
||||
<i class="bi bi-arrow-left-right"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('site_admin.group_member_remove', group_id=group.id, user_id=m.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('{{ m.username }} aus Gruppe entfernen?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Entfernen">
|
||||
<i class="bi bi-person-dash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-muted text-center py-3">Keine Mitglieder</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benutzer hinzufügen -->
|
||||
<div class="col-md-5">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header"><i class="bi bi-person-plus-fill me-2"></i>Benutzer hinzufügen</div>
|
||||
<div class="card-body">
|
||||
{% if non_members %}
|
||||
<form method="post" action="{{ url_for('site_admin.group_member_add', group_id=group.id) }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Benutzer auswählen</label>
|
||||
<select name="user_id" class="form-select">
|
||||
{% for u in non_members %}
|
||||
<option value="{{ u.id }}">{{ u.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Rolle</label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-person-plus-fill me-1"></i>Hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-3">Alle Benutzer sind bereits Mitglied.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
59
web/templates/admin/groups.html
Normal file
59
web/templates/admin/groups.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Gruppen{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-collection-fill me-2"></i>Gruppen</h2>
|
||||
<a href="{{ url_for('site_admin.group_create') }}" class="btn btn-success">
|
||||
<i class="bi bi-plus-lg me-1"></i>Neue Gruppe
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>Name</th><th>Beschreibung</th><th>Mitglieder</th><th>DB</th><th>Erstellt</th><th class="text-end">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for g in groups %}
|
||||
<tr>
|
||||
<td class="text-muted small">{{ g.id }}</td>
|
||||
<td class="fw-semibold">{{ g.name }}</td>
|
||||
<td class="text-muted small">{{ g.description or '—' }}</td>
|
||||
<td>{{ g.member_count }}</td>
|
||||
<td>
|
||||
{% if g.has_db %}
|
||||
<span class="badge bg-success"><i class="bi bi-database-fill-check me-1"></i>Konfiguriert</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Keine DB</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">{{ g.created_at | fmt_dt }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('site_admin.view_group', group_id=g.id) }}" class="btn btn-sm btn-outline-info" title="Daten browsen">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('site_admin.group_members', group_id=g.id) }}" class="btn btn-sm btn-outline-secondary" title="Mitglieder">
|
||||
<i class="bi bi-people-fill"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('site_admin.group_edit', group_id=g.id) }}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="post" action="{{ url_for('site_admin.group_delete', group_id=g.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Gruppe {{ g.name }} löschen?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Löschen">
|
||||
<i class="bi bi-trash3"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-muted text-center py-4">Noch keine Gruppen vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
50
web/templates/admin/user_edit.html
Normal file
50
web/templates/admin/user_edit.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}{{ 'Benutzer bearbeiten' if user else 'Neuer Benutzer' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center gap-2 mb-4">
|
||||
<a href="{{ url_for('site_admin.users') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h2 class="mb-0">{{ 'Benutzer bearbeiten: ' ~ user.username if user else 'Neuer Benutzer' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Benutzername *</label>
|
||||
<input type="text" name="username" class="form-control" required
|
||||
value="{{ user.username if user else request.form.get('username', '') }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ 'Neues Passwort (leer lassen = unverändert)' if user else 'Passwort *' }}</label>
|
||||
<input type="password" name="password" class="form-control"
|
||||
{{ '' if user else 'required' }}>
|
||||
{% if not user %}
|
||||
<div class="form-text">Mindestens 8 Zeichen empfohlen.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" name="is_site_admin" id="is_site_admin" class="form-check-input"
|
||||
value="1" {{ 'checked' if user and user.is_site_admin }}>
|
||||
<label class="form-check-label" for="is_site_admin">
|
||||
<span class="text-danger fw-semibold"><i class="bi bi-shield-fill me-1"></i>Site Admin</span>
|
||||
<small class="text-muted d-block">Voller Zugriff auf alle Gruppen und Einstellungen</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>{{ 'Speichern' if user else 'Erstellen' }}
|
||||
</button>
|
||||
<a href="{{ url_for('site_admin.users') }}" class="btn btn-outline-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
53
web/templates/admin/users.html
Normal file
53
web/templates/admin/users.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Benutzer{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-people-fill me-2"></i>Benutzer</h2>
|
||||
<a href="{{ url_for('site_admin.user_create') }}" class="btn btn-success">
|
||||
<i class="bi bi-person-plus-fill me-1"></i>Neuer Benutzer
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr><th>Benutzer</th><th>Gruppen</th><th>Site Admin</th><th>Erstellt</th><th class="text-end">Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ u.username }}</td>
|
||||
<td>
|
||||
{% for g in u.groups %}
|
||||
<span class="badge bg-secondary me-1">{{ g.name }}
|
||||
{% if g.role == 'admin' %}<i class="bi bi-star-fill ms-1 text-warning"></i>{% endif %}
|
||||
</span>
|
||||
{% else %}<span class="text-muted small">Keine</span>{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.is_site_admin %}
|
||||
<span class="badge bg-danger"><i class="bi bi-shield-fill me-1"></i>Site Admin</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">{{ u.created_at | fmt_dt }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('site_admin.user_edit', user_id=u.id) }}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="post" action="{{ url_for('site_admin.user_delete', user_id=u.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Benutzer {{ u.username }} löschen?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Löschen">
|
||||
<i class="bi bi-trash3"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-muted text-center py-4">Keine Benutzer vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
63
web/templates/auth/admin_login.html
Normal file
63
web/templates/auth/admin_login.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCLogger – Site Admin Login</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0d1117; }
|
||||
.login-card { width: 100%; max-width: 400px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-fill-check fs-1 text-danger"></i>
|
||||
<h3 class="fw-bold mt-2">Site Admin</h3>
|
||||
<p class="text-muted small">Administrativer Zugang</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card border-danger">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Benutzername</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text text-danger"><i class="bi bi-person-fill"></i></span>
|
||||
<input type="text" name="username" class="form-control" required autofocus
|
||||
value="{{ request.form.get('username', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Passwort</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text text-danger"><i class="bi bi-lock-fill"></i></span>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger w-100">
|
||||
<i class="bi bi-shield-lock-fill me-1"></i> Admin Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('auth.login') }}" class="text-muted small">
|
||||
<i class="bi bi-arrow-left me-1"></i>Zurück zum normalen Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
63
web/templates/auth/login.html
Normal file
63
web/templates/auth/login.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCLogger – Login</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0d1117; }
|
||||
.login-card { width: 100%; max-width: 400px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-database-fill-gear fs-1 text-success"></i>
|
||||
<h3 class="fw-bold mt-2">MCLogger</h3>
|
||||
<p class="text-muted small">Panel Login</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Benutzername</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person-fill"></i></span>
|
||||
<input type="text" name="username" class="form-control" required autofocus
|
||||
value="{{ request.form.get('username', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Passwort</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> Einloggen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('auth.admin_login') }}" class="text-muted small">
|
||||
<i class="bi bi-shield-fill me-1"></i>Site Admin Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
173
web/templates/base.html
Normal file
173
web/templates/base.html
Normal file
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}MCLogger{% endblock %} — Panel</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="d-flex" id="wrapper">
|
||||
<!-- ── Sidebar ─────────────────────────────────────────── -->
|
||||
<nav id="sidebar" class="d-flex flex-column p-3">
|
||||
<div class="sidebar-brand mb-3 text-center">
|
||||
<i class="bi bi-database-fill-gear fs-2 text-success"></i>
|
||||
<div class="fw-bold mt-1">MCLogger</div>
|
||||
{% if session.get('group_name') %}
|
||||
<span class="badge bg-success mt-1">{{ session.group_name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% set perms = session.get('permissions', {}) %}
|
||||
{% set is_admin = session.get('is_site_admin') or session.get('role') == 'admin' %}
|
||||
|
||||
<ul class="nav flex-column gap-1">
|
||||
{% if perms.get('view_dashboard', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.dashboard' }}" href="{{ url_for('panel.dashboard') }}">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_players', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint in ['panel.players','panel.player_detail'] }}" href="{{ url_for('panel.players') }}">
|
||||
<i class="bi bi-people-fill"></i> Players
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_sessions', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.sessions' }}" href="{{ url_for('panel.sessions') }}">
|
||||
<i class="bi bi-clock-history"></i> Sessions
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_chat', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.chat' }}" href="{{ url_for('panel.chat') }}">
|
||||
<i class="bi bi-chat-dots-fill"></i> Chat
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_commands', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.commands' }}" href="{{ url_for('panel.commands') }}">
|
||||
<i class="bi bi-terminal-fill"></i> Commands
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_deaths', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.deaths' }}" href="{{ url_for('panel.deaths') }}">
|
||||
<i class="bi bi-heartbreak-fill"></i> Deaths
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_blocks', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.blocks' }}" href="{{ url_for('panel.blocks') }}">
|
||||
<i class="bi bi-bricks"></i> Block Events
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_proxy') or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.proxy' }}" href="{{ url_for('panel.proxy') }}">
|
||||
<i class="bi bi-diagram-3-fill"></i> Proxy Events
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_server_events') or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.server_events' }}" href="{{ url_for('panel.server_events') }}">
|
||||
<i class="bi bi-server"></i> Server Events
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_perms') or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.perms' }}" href="{{ url_for('panel.perms') }}">
|
||||
<i class="bi bi-shield-lock-fill"></i> Permissions
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<hr class="my-2">
|
||||
|
||||
<!-- Gruppen-Switcher -->
|
||||
{% if not session.get('is_site_admin') and user_groups and user_groups|length > 1 %}
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Gruppe wechseln:</small>
|
||||
{% for g in user_groups %}
|
||||
<a href="{{ url_for('auth.switch_group', group_id=g.id) }}"
|
||||
class="btn btn-sm w-100 mt-1 {{ 'btn-success' if g.id == session.get('group_id') else 'btn-outline-secondary' }}">
|
||||
{{ g.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Admin-Links -->
|
||||
{% if session.get('role') == 'admin' and not session.get('is_site_admin') %}
|
||||
<a href="{{ url_for('group_admin.dashboard') }}" class="btn btn-outline-warning btn-sm mb-1">
|
||||
<i class="bi bi-gear-fill"></i> Gruppe verwalten
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if session.get('is_site_admin') %}
|
||||
{% if session.get('admin_viewing') %}
|
||||
<a href="{{ url_for('site_admin.stop_view') }}" class="btn btn-warning btn-sm mb-1">
|
||||
<i class="bi bi-arrow-left"></i> Zurück zum Admin
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('site_admin.dashboard') }}" class="btn btn-outline-danger btn-sm mb-1">
|
||||
<i class="bi bi-shield-fill"></i> Site Admin
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="small text-muted mb-1">
|
||||
<i class="bi bi-circle-fill text-success me-1" style="font-size:.5rem"></i>
|
||||
<span id="online-count">—</span> Online
|
||||
</div>
|
||||
<small class="text-muted d-block mb-1">{{ session.get('username', '') }}</small>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-danger btn-sm w-100">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── Hauptinhalt ───────────────────────────────────── -->
|
||||
<div id="page-content" class="flex-grow-1 overflow-auto">
|
||||
<div class="topbar d-flex align-items-center justify-content-between px-4 py-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="sidebarToggle"><i class="bi bi-list"></i></button>
|
||||
<h6 class="mb-0 fw-semibold">{% block page_title %}{% endblock %}</h6>
|
||||
<small class="text-muted">{{ now.strftime('%d.%m.%Y %H:%M') }}</small>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="px-4 pt-2">
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
|
||||
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<main class="px-4 py-3">{% block content %}{% endblock %}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
69
web/templates/blocks.html
Normal file
69
web/templates/blocks.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Block-Events{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-bricks me-2"></i>Block-Events{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-2">
|
||||
<select name="type" class="form-select form-select-sm">
|
||||
<option value="">All Types</option>
|
||||
{% for t in ['break','place','ignite','burn','explode','fade','grow','dispense'] %}
|
||||
<option {{ 'selected' if t == event_type }}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Spieler…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" name="block" class="form-control form-control-sm" placeholder="Block-Typ…" value="{{ block }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="world" class="form-select form-select-sm">
|
||||
<option value="">All Worlds</option>
|
||||
{% for w in worlds %}<option {{ 'selected' if w == world }}>{{ w }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">All Servers</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('blocks') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} block events</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Type</th><th>Player</th><th>Block</th><th>World</th><th>Position</th><th>Tool</th><th>Silk</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td>
|
||||
{% set colors = {'break':'danger','place':'success','ignite':'warning','burn':'orange','explode':'dark'} %}
|
||||
<span class="badge bg-{{ colors.get(r.event_type,'secondary') }}">{{ r.event_type }}</span>
|
||||
</td>
|
||||
<td class="small">{{ r.player_name or '—' }}</td>
|
||||
<td class="small font-monospace">{{ r.block_type }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.world }}</span></td>
|
||||
<td class="small text-muted">{{ r.x }}, {{ r.y }}, {{ r.z }}</td>
|
||||
<td class="small">{{ r.tool or '—' }}</td>
|
||||
<td>{% if r.is_silk %}<i class="bi bi-check-circle-fill text-success"></i>{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">No block events</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
58
web/templates/chat.html
Normal file
58
web/templates/chat.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Chat Log{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-chat-dots-fill me-2"></i>Chat Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label small">Message</label>
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search…" value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small">Server</label>
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">Alle</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small">From</label>
|
||||
<input type="date" name="from" class="form-control form-control-sm" value="{{ date_from }}">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small">To</label>
|
||||
<input type="date" name="to" class="form-control form-control-sm" value="{{ date_to }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('chat') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} messages</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Player</th><th>Server</th><th>Channel</th><th>Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
|
||||
<td><span class="badge bg-primary">{{ r.channel or 'global' }}</span></td>
|
||||
<td class="small">{{ r.message }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">No messages found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
51
web/templates/commands.html
Normal file
51
web/templates/commands.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Commands{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-terminal-fill me-2"></i>Commands{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Command text…" value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">All Servers</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('commands') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} commands</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Player</th><th>Server</th><th>Command</th><th>Position</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
|
||||
<td class="small font-monospace text-warning">{{ r.command }}</td>
|
||||
<td class="small text-muted">
|
||||
{% if r.world %}{{ r.world }} ({{ r.x|round(0)|int }}, {{ r.y|round(0)|int }}, {{ r.z|round(0)|int }}){% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">No commands</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
221
web/templates/dashboard.html
Normal file
221
web/templates/dashboard.html
Normal file
@@ -0,0 +1,221 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-speedometer2 me-2"></i>Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- ── Statistik-Karten ────────────────────────────────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
{% 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 %}
|
||||
<div class="col-6 col-md-3 col-xl-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="stat-icon bg-{{ color }} bg-opacity-25">
|
||||
<i class="bi {{ icon }} text-{{ color }}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-value">{{ value | int }}</div>
|
||||
<div class="stat-label">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ── Zeile 2: Online-Spieler + Letzte Aktivität ────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Online-Spieler -->
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-circle-fill text-success me-2 blink" style="font-size:.5rem"></i>Online Players</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="refreshOnline()">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="online-table">
|
||||
{% if online %}
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Player</th><th>Server</th><th>Country</th><th>Since</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in online %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('player_detail', uuid='') }}">{{ s.username }}</a></td>
|
||||
<td><span class="badge bg-secondary">{{ s.server_name }}</span></td>
|
||||
<td class="small text-muted">{{ s.country or '—' }}</td>
|
||||
<td class="small text-muted">{{ s.login_time | fmt_dt }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-moon-stars-fill fs-3"></i><br>
|
||||
No players online
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Letzte Aktivität -->
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-activity me-2"></i>Last 24h Activity
|
||||
</div>
|
||||
<div class="card-body p-0" style="overflow-y:auto; max-height:320px;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr><th>Time</th><th>Type</th><th>Player</th><th>Server</th><th>Detail</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recent %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td>
|
||||
{% set badge = {'chat':'primary','command':'warning','block':'secondary','death':'danger'} %}
|
||||
<span class="badge bg-{{ badge.get(r.source,'light') }}">{{ r.source }}</span>
|
||||
</td>
|
||||
<td class="small">{{ r.player_name or '—' }}</td>
|
||||
<td class="small">{{ r.server_name or '—' }}</td>
|
||||
<td class="small text-truncate" style="max-width:200px;" title="{{ r.detail }}">{{ r.detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Zeile 3: Charts ────────────────────────────────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Block-Events Chart -->
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-bricks me-2"></i>Block Events (last 7 days)</div>
|
||||
<div class="card-body">
|
||||
<canvas id="blockChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Todesursachen -->
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-heartbreak-fill me-2"></i>Death Causes (7d)</div>
|
||||
<div class="card-body">
|
||||
<canvas id="deathChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Spieler -->
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-trophy-fill me-2"></i>Top Playtime</div>
|
||||
<div class="card-body p-0" style="overflow-y:auto;max-height:240px;">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
{% for p in top_players %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}. {{ p.username }}</td>
|
||||
<td class="text-end text-muted small">{{ p.total_playtime_sec | fmt_duration }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Zeile 4: Server-Events ─────────────────────────── -->
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-server me-2"></i>Server Events (last 24h)
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark"><tr><th>Time</th><th>Type</th><th>Server</th><th>Message</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in server_events %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ e.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-info text-dark">{{ e.event_type }}</span></td>
|
||||
<td class="small">{{ e.server_name }}</td>
|
||||
<td class="small">{{ e.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Block-Chart
|
||||
const blockCtx = document.getElementById('blockChart');
|
||||
new Chart(blockCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ block_chart | map(attribute='day') | list | tojson }},
|
||||
datasets: [{
|
||||
label: 'Block-Events',
|
||||
data: {{ block_chart | map(attribute='cnt') | list | tojson }},
|
||||
backgroundColor: 'rgba(25,135,84,0.7)',
|
||||
borderColor: 'rgba(25,135,84,1)',
|
||||
borderWidth: 1,
|
||||
}]
|
||||
},
|
||||
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
|
||||
});
|
||||
|
||||
// Todesursachen-Chart
|
||||
const deathCtx = document.getElementById('deathChart');
|
||||
new Chart(deathCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: {{ death_causes | map(attribute='cause') | list | tojson }},
|
||||
datasets: [{
|
||||
data: {{ death_causes | map(attribute='cnt') | list | tojson }},
|
||||
backgroundColor: ['#dc3545','#fd7e14','#ffc107','#198754','#0dcaf0','#6f42c1','#d63384','#6c757d'],
|
||||
}]
|
||||
},
|
||||
options: { plugins: { legend: { position: 'bottom', labels: { font: { size:10 } } } } }
|
||||
});
|
||||
|
||||
// Live Online-Count aktualisieren
|
||||
function refreshOnline() {
|
||||
fetch('/api/online')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('online-count').textContent = data.length;
|
||||
});
|
||||
}
|
||||
setInterval(refreshOnline, 30000);
|
||||
refreshOnline();
|
||||
</script>
|
||||
{% endblock %}
|
||||
49
web/templates/deaths.html
Normal file
49
web/templates/deaths.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Deaths{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-heartbreak-fill me-2"></i>Deaths{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="cause" class="form-select form-select-sm">
|
||||
<option value="">All Causes</option>
|
||||
{% for c in causes %}<option {{ 'selected' if c == cause }}>{{ c }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('deaths') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} deaths</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Player</th><th>Cause</th><th>Killer</th><th>Killer Type</th><th>Level</th><th>World</th><th>Death Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td class="small fw-semibold">{{ r.player_name }}</td>
|
||||
<td><span class="badge bg-danger">{{ r.cause or '—' }}</span></td>
|
||||
<td class="small">{{ r.killer_name or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.killer_type or '—' }}</td>
|
||||
<td class="small">{{ r.exp_level }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.world }}</span></td>
|
||||
<td class="small text-muted">{{ r.death_message or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">No deaths</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
46
web/templates/group_admin/base.html
Normal file
46
web/templates/group_admin/base.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Gruppen Admin{% endblock %} — {{ session.get('group_name','') }}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-dark bg-warning bg-opacity-75">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand fw-bold text-dark" href="{{ url_for('group_admin.dashboard') }}">
|
||||
<i class="bi bi-gear-fill me-2"></i>{{ session.get('group_name', 'Gruppe') }} — Admin
|
||||
</a>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<a href="{{ url_for('group_admin.dashboard') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.dashboard' }}">Dashboard</a>
|
||||
<a href="{{ url_for('group_admin.members') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.members' }}">Mitglieder</a>
|
||||
<a href="{{ url_for('group_admin.database') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.database' }}">Datenbank</a>
|
||||
<a href="{{ url_for('panel.dashboard') }}" class="btn btn-outline-dark btn-sm">
|
||||
<i class="bi bi-grid me-1"></i>Panel
|
||||
</a>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-dark btn-sm">
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
|
||||
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
77
web/templates/group_admin/dashboard.html
Normal file
77
web/templates/group_admin/dashboard.html
Normal file
@@ -0,0 +1,77 @@
|
||||
{% extends "group_admin/base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-gear-fill text-warning me-2"></i>Gruppenadmin: {{ session.get('group_name') }}</h2>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold text-warning">{{ stats.member_count }}</div>
|
||||
<div class="text-muted">Mitglieder</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold {{ 'text-success' if stats.db_configured else 'text-danger' }}">
|
||||
{{ 'Ja' if stats.db_configured else 'Nein' }}
|
||||
</div>
|
||||
<div class="text-muted">DB konfiguriert</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold text-info">{{ stats.admin_count }}</div>
|
||||
<div class="text-muted">Admins</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-people-fill me-2"></i>Schnellzugriff
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column gap-2">
|
||||
<a href="{{ url_for('group_admin.members') }}" class="btn btn-outline-warning">
|
||||
<i class="bi bi-people-fill me-2"></i>Mitglieder verwalten
|
||||
</a>
|
||||
<a href="{{ url_for('group_admin.database') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-database-fill-gear me-2"></i>Datenbank konfigurieren
|
||||
</a>
|
||||
<a href="{{ url_for('panel.dashboard') }}" class="btn btn-outline-success">
|
||||
<i class="bi bi-speedometer2 me-2"></i>Panel öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Gruppeninfo</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-5">Name</dt>
|
||||
<dd class="col-sm-7">{{ session.get('group_name') }}</dd>
|
||||
<dt class="col-sm-5">Deine Rolle</dt>
|
||||
<dd class="col-sm-7"><span class="badge bg-warning text-dark">Admin</span></dd>
|
||||
<dt class="col-sm-5">Datenbank</dt>
|
||||
<dd class="col-sm-7">
|
||||
{% if stats.db_configured %}
|
||||
<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Verbunden</span>
|
||||
{% else %}
|
||||
<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>Nicht konfiguriert</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
98
web/templates/group_admin/database.html
Normal file
98
web/templates/group_admin/database.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends "group_admin/base.html" %}
|
||||
{% block title %}Datenbank{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-database-fill-gear me-2"></i>MC Datenbank konfigurieren</h2>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-7">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header">Verbindungsdaten</div>
|
||||
<div class="card-body">
|
||||
{% if test_result is defined %}
|
||||
<div class="alert {{ 'alert-success' if test_result else 'alert-danger' }}">
|
||||
{% if test_result %}
|
||||
<i class="bi bi-check-circle-fill me-2"></i>Verbindung erfolgreich! Daten wurden gespeichert.
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle-fill me-2"></i>Verbindung fehlgeschlagen: {{ test_error }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Host *</label>
|
||||
<input type="text" name="host" class="form-control" required
|
||||
placeholder="localhost"
|
||||
value="{{ creds.host if creds else request.form.get('host', '') }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Port *</label>
|
||||
<input type="number" name="port" class="form-control" required
|
||||
value="{{ creds.port if creds else request.form.get('port', '3306') }}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Datenbank *</label>
|
||||
<input type="text" name="database" class="form-control" required
|
||||
placeholder="mclogger"
|
||||
value="{{ creds.database if creds else request.form.get('database', '') }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Benutzer *</label>
|
||||
<input type="text" name="user" class="form-control" required
|
||||
value="{{ creds.user if creds else request.form.get('user', '') }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Passwort</label>
|
||||
<input type="password" name="password" class="form-control"
|
||||
placeholder="{{ '(unverändert)' if creds else '' }}">
|
||||
{% if creds %}
|
||||
<div class="form-text">Leer lassen um das bestehende Passwort beizubehalten.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" name="action" value="test_save" class="btn btn-success">
|
||||
<i class="bi bi-plug-fill me-1"></i>Testen & Speichern
|
||||
</button>
|
||||
{% if creds %}
|
||||
<button type="submit" name="action" value="delete" class="btn btn-outline-danger"
|
||||
onclick="return confirm('DB-Konfiguration löschen?')">
|
||||
<i class="bi bi-trash3 me-1"></i>Entfernen
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Info</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted">
|
||||
Gib hier die Verbindungsdaten zu deiner <strong>MCLogger MySQL-Datenbank</strong> ein.
|
||||
Das Panel liest nur Daten (SELECT) — schreibender Zugriff ist nicht nötig.
|
||||
</p>
|
||||
<p class="small text-muted">
|
||||
Die Zugangsdaten werden <strong>verschlüsselt</strong> gespeichert und sind nur für deine Gruppe sichtbar.
|
||||
</p>
|
||||
<hr>
|
||||
<p class="small text-muted mb-1"><strong>Benötigte Tabellen:</strong></p>
|
||||
<ul class="small text-muted">
|
||||
<li>player_sessions</li>
|
||||
<li>chat_messages</li>
|
||||
<li>player_commands</li>
|
||||
<li>block_events</li>
|
||||
<li>player_deaths</li>
|
||||
<li>proxy_events</li>
|
||||
<li>server_events</li>
|
||||
<li>permission_changes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
54
web/templates/group_admin/member_edit.html
Normal file
54
web/templates/group_admin/member_edit.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends "group_admin/base.html" %}
|
||||
{% block title %}Berechtigungen – {{ member.username }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center gap-2 mb-4">
|
||||
<a href="{{ url_for('group_admin.members') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h2 class="mb-0">Berechtigungen: <span class="text-warning">{{ member.username }}</span></h2>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-shield-lock-fill me-2"></i>Panel-Berechtigungen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Rolle</label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="member" {{ 'selected' if member.role == 'member' }}>Member</option>
|
||||
<option value="admin" {{ 'selected' if member.role == 'admin' }}>Admin</option>
|
||||
</select>
|
||||
<div class="form-text">Admins können Mitglieder und die DB-Verbindung verwalten.</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<p class="form-label mb-2">Panel-Zugriff</p>
|
||||
<div class="row g-2">
|
||||
{% for key, label in all_permissions %}
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
name="perm_{{ key }}" id="perm_{{ key }}"
|
||||
{{ 'checked' if perms.get(key, True) }}>
|
||||
<label class="form-check-label" for="perm_{{ key }}">{{ label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-check-lg me-1"></i>Speichern
|
||||
</button>
|
||||
<a href="{{ url_for('group_admin.members') }}" class="btn btn-outline-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
65
web/templates/group_admin/members.html
Normal file
65
web/templates/group_admin/members.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{% extends "group_admin/base.html" %}
|
||||
{% block title %}Mitglieder{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-people-fill me-2"></i>Mitglieder</h2>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Mitgliederliste -->
|
||||
<div class="col-md-8">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header">Aktuelle Mitglieder ({{ members|length }})</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Benutzer</th><th>Rolle</th><th class="text-end">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in members %}
|
||||
<tr>
|
||||
<td>{{ m.username }}</td>
|
||||
<td>
|
||||
{% if m.role == 'admin' %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Member</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if m.id != session.get('user_id') %}
|
||||
<a href="{{ url_for('group_admin.member_edit', user_id=m.id) }}" class="btn btn-sm btn-outline-warning" title="Berechtigungen">
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
</a>
|
||||
<form method="post" action="{{ url_for('group_admin.member_remove', user_id=m.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('{{ m.username }} entfernen?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Entfernen">
|
||||
<i class="bi bi-person-dash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="text-muted small">Du</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-muted text-center py-3">Keine Mitglieder</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benutzer einladen (nur via Benutzername - Site Admin fügt Benutzer hinzu, Gruppen admin kann nur bestehende Mitglieder verwalten) -->
|
||||
<div class="col-md-4">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Hinweis</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">
|
||||
Neue Mitglieder müssen vom <strong>Site Admin</strong> zur Gruppe hinzugefügt werden.
|
||||
</p>
|
||||
<p class="text-muted small">
|
||||
Als Gruppenadmin kannst du Berechtigungen bestehender Mitglieder verwalten und Mitglieder entfernen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
49
web/templates/login.html
Normal file
49
web/templates/login.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCLogger – Login</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body class="d-flex align-items-center justify-content-center min-vh-100 bg-dark">
|
||||
<div class="card shadow-lg" style="width: 380px;">
|
||||
<div class="card-body p-5">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-database-fill-gear text-success" style="font-size: 3rem;"></i>
|
||||
<h4 class="mt-2 fw-bold">MCLogger</h4>
|
||||
<p class="text-muted small">Admin-Interface · SimolZimol</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-2">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person-fill"></i></span>
|
||||
<input type="text" name="username" class="form-control" placeholder="admin" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
|
||||
<input type="password" name="password" class="form-control" placeholder="••••••••" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i>Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
69
web/templates/panel/blocks.html
Normal file
69
web/templates/panel/blocks.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Block-Events{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-bricks me-2"></i>Block-Events{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-2">
|
||||
<select name="type" class="form-select form-select-sm">
|
||||
<option value="">All Types</option>
|
||||
{% for t in ['break','place','ignite','burn','explode','fade','grow','dispense'] %}
|
||||
<option {{ 'selected' if t == event_type }}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Spieler…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" name="block" class="form-control form-control-sm" placeholder="Block-Typ…" value="{{ block }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="world" class="form-select form-select-sm">
|
||||
<option value="">All Worlds</option>
|
||||
{% for w in worlds %}<option {{ 'selected' if w == world }}>{{ w }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">All Servers</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.blocks') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} block events</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Type</th><th>Player</th><th>Block</th><th>World</th><th>Position</th><th>Tool</th><th>Silk</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td>
|
||||
{% set colors = {'break':'danger','place':'success','ignite':'warning','burn':'orange','explode':'dark'} %}
|
||||
<span class="badge bg-{{ colors.get(r.event_type,'secondary') }}">{{ r.event_type }}</span>
|
||||
</td>
|
||||
<td class="small">{{ r.player_name or '—' }}</td>
|
||||
<td class="small font-monospace">{{ r.block_type }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.world }}</span></td>
|
||||
<td class="small text-muted">{{ r.x }}, {{ r.y }}, {{ r.z }}</td>
|
||||
<td class="small">{{ r.tool or '—' }}</td>
|
||||
<td>{% if r.is_silk %}<i class="bi bi-check-circle-fill text-success"></i>{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">No block events</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
58
web/templates/panel/chat.html
Normal file
58
web/templates/panel/chat.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Chat Log{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-chat-dots-fill me-2"></i>Chat Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label small">Message</label>
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search…" value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small">Server</label>
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">Alle</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small">From</label>
|
||||
<input type="date" name="from" class="form-control form-control-sm" value="{{ date_from }}">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small">To</label>
|
||||
<input type="date" name="to" class="form-control form-control-sm" value="{{ date_to }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.chat') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} messages</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Player</th><th>Server</th><th>Channel</th><th>Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
|
||||
<td><span class="badge bg-primary">{{ r.channel or 'global' }}</span></td>
|
||||
<td class="small">{{ r.message }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">No messages found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
51
web/templates/panel/commands.html
Normal file
51
web/templates/panel/commands.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Commands{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-terminal-fill me-2"></i>Commands{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Command text…" value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">All Servers</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.commands') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} commands</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Player</th><th>Server</th><th>Command</th><th>Position</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
|
||||
<td class="small font-monospace text-warning">{{ r.command }}</td>
|
||||
<td class="small text-muted">
|
||||
{% if r.world %}{{ r.world }} ({{ r.x|round(0)|int }}, {{ r.y|round(0)|int }}, {{ r.z|round(0)|int }}){% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">No commands</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
194
web/templates/panel/dashboard.html
Normal file
194
web/templates/panel/dashboard.html
Normal file
@@ -0,0 +1,194 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-speedometer2 me-2"></i>Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- ── Statistik-Karten ────────────────────────────────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
{% 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 %}
|
||||
<div class="col-6 col-md-3 col-xl-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="stat-icon bg-{{ color }} bg-opacity-25">
|
||||
<i class="bi {{ icon }} text-{{ color }}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-value">{{ value | int }}</div>
|
||||
<div class="stat-label">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ── Zeile 2: Online-Spieler + Letzte Aktivität ────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-circle-fill text-success me-2 blink" style="font-size:.5rem"></i>Online Players</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="refreshOnline()">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="online-table">
|
||||
{% if online %}
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Player</th><th>Server</th><th>Since</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in online %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('panel.player_detail', uuid=s.get('player_uuid','')) }}">{{ s.player_name }}</a></td>
|
||||
<td><span class="badge bg-secondary">{{ s.server_name }}</span></td>
|
||||
<td class="small text-muted">{{ s.login_time | fmt_dt }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-moon-stars-fill fs-3"></i><br>No players online
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-activity me-2"></i>Last 24h Activity
|
||||
</div>
|
||||
<div class="card-body p-0" style="overflow-y:auto; max-height:320px;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr><th>Time</th><th>Type</th><th>Player</th><th>Server</th><th>Detail</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recent %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td>
|
||||
{% set badge = {'chat':'primary','command':'warning','block':'secondary','death':'danger'} %}
|
||||
<span class="badge bg-{{ badge.get(r.source,'light') }}">{{ r.source }}</span>
|
||||
</td>
|
||||
<td class="small">{{ r.player_name or '—' }}</td>
|
||||
<td class="small">{{ r.server_name or '—' }}</td>
|
||||
<td class="small text-truncate" style="max-width:200px;" title="{{ r.detail }}">{{ r.detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Zeile 3: Charts ────────────────────────────────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-bricks me-2"></i>Block Events (last 7 days)</div>
|
||||
<div class="card-body">
|
||||
<canvas id="blockChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-heartbreak-fill me-2"></i>Death Causes (7d)</div>
|
||||
<div class="card-body">
|
||||
<canvas id="deathChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-trophy-fill me-2"></i>Top Playtime</div>
|
||||
<div class="card-body p-0" style="overflow-y:auto;max-height:240px;">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
{% for p in top_players %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}. {{ p.username }}</td>
|
||||
<td class="text-end text-muted small">{{ p.total_playtime_sec | fmt_duration }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Zeile 4: Server-Events ─────────────────────────── -->
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-server me-2"></i>Server Events (last 24h)</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark"><tr><th>Time</th><th>Type</th><th>Server</th><th>Message</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in server_events %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ e.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-info text-dark">{{ e.event_type }}</span></td>
|
||||
<td class="small">{{ e.server_name }}</td>
|
||||
<td class="small">{{ e.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const blockCtx = document.getElementById('blockChart');
|
||||
new Chart(blockCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ block_chart | map(attribute='day') | list | tojson }},
|
||||
datasets: [{ label: 'Block-Events', data: {{ block_chart | map(attribute='cnt') | list | tojson }},
|
||||
backgroundColor: 'rgba(25,135,84,0.7)', borderColor: 'rgba(25,135,84,1)', borderWidth: 1 }]
|
||||
},
|
||||
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
|
||||
});
|
||||
const deathCtx = document.getElementById('deathChart');
|
||||
new Chart(deathCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: {{ death_causes | map(attribute='cause') | list | tojson }},
|
||||
datasets: [{ data: {{ death_causes | map(attribute='cnt') | list | tojson }},
|
||||
backgroundColor: ['#dc3545','#fd7e14','#ffc107','#198754','#0dcaf0','#6f42c1','#d63384','#6c757d'] }]
|
||||
},
|
||||
options: { plugins: { legend: { position: 'bottom', labels: { font: { size:10 } } } } }
|
||||
});
|
||||
function refreshOnline() {
|
||||
fetch('/api/online').then(r => r.json()).then(data => {
|
||||
document.getElementById('online-count').textContent = data.length;
|
||||
});
|
||||
}
|
||||
setInterval(refreshOnline, 30000);
|
||||
refreshOnline();
|
||||
</script>
|
||||
{% endblock %}
|
||||
49
web/templates/panel/deaths.html
Normal file
49
web/templates/panel/deaths.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Deaths{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-heartbreak-fill me-2"></i>Deaths{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="cause" class="form-select form-select-sm">
|
||||
<option value="">All Causes</option>
|
||||
{% for c in causes %}<option {{ 'selected' if c == cause }}>{{ c }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.deaths') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} deaths</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Player</th><th>Cause</th><th>Killer</th><th>Killer Type</th><th>Level</th><th>World</th><th>Death Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td class="small fw-semibold">{{ r.player_name }}</td>
|
||||
<td><span class="badge bg-danger">{{ r.cause or '—' }}</span></td>
|
||||
<td class="small">{{ r.killer_name or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.killer_type or '—' }}</td>
|
||||
<td class="small">{{ r.exp_level }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.world }}</span></td>
|
||||
<td class="small text-muted">{{ r.death_message or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">No deaths</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
24
web/templates/panel/no_db.html
Normal file
24
web/templates/panel/no_db.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}No Database{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-database-fill-x me-2"></i>Keine Datenbank{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-6 text-center">
|
||||
<i class="bi bi-database-fill-x display-1 text-muted mb-4"></i>
|
||||
<h3 class="mb-3">Keine Datenbank konfiguriert</h3>
|
||||
<p class="text-muted mb-4">
|
||||
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 %}
|
||||
</p>
|
||||
{% if session.get('role') == 'admin' %}
|
||||
<a href="{{ url_for('group_admin.database') }}" class="btn btn-success btn-lg">
|
||||
<i class="bi bi-database-fill-gear me-2"></i>Datenbank konfigurieren
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
64
web/templates/panel/perms.html
Normal file
64
web/templates/panel/perms.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Permissions{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-shield-lock-fill me-2"></i>Permissions{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Target player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="plugin" class="form-select form-select-sm">
|
||||
<option value="">All Plugins</option>
|
||||
{% for pl in plugins %}<option {{ 'selected' if pl == plugin_filter }}>{{ pl }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="type" class="form-control form-control-sm" placeholder="Event type…" value="{{ etype }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.perms') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} permission events</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Plugin</th><th>Event Type</th><th>Target Player</th><th>Actor</th><th>Target Type</th><th>Target ID</th><th>Action</th><th>Server</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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',
|
||||
} %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.plugin_name or '—' }}</span></td>
|
||||
<td><span class="badge bg-{{ badge_colors.get(r.event_type,'secondary') }} text-wrap text-start" style="font-size:.7rem;">{{ r.event_type }}</span></td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td class="small">{{ r.actor_name or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.target_type or '—' }}</td>
|
||||
<td class="small text-muted text-truncate" style="max-width:120px;" title="{{ r.target_id }}">{{ r.target_id or '—' }}</td>
|
||||
<td class="small text-truncate" style="max-width:200px;" title="{{ r.action }}">{{ r.action or '—' }}</td>
|
||||
<td><span class="badge bg-dark">{{ r.server_name or '—' }}</span></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="text-center text-muted py-4">No permission events found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
142
web/templates/panel/player_detail.html
Normal file
142
web/templates/panel/player_detail.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ player.username }}{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-person-fill me-2"></i>{{ player.username }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center py-4">
|
||||
<img src="https://minotar.net/avatar/{{ player.username }}/80"
|
||||
class="rounded mb-3" alt="{{ player.username }}" onerror="this.src='/static/img/default.png'">
|
||||
<h5 class="fw-bold mb-1">{{ player.username }}</h5>
|
||||
{% if player.is_op %}
|
||||
<span class="badge bg-warning text-dark mb-2"><i class="bi bi-shield-fill"></i> OP</span>
|
||||
{% endif %}
|
||||
<table class="table table-sm mt-2 text-start">
|
||||
<tr><th>UUID</th><td class="small text-break">{{ player.uuid }}</td></tr>
|
||||
<tr><th>IP</th><td class="small">{{ player.ip_address or '—' }}</td></tr>
|
||||
<tr><th>Locale</th><td class="small">{{ player.locale or '—' }}</td></tr>
|
||||
<tr><th>Playtime</th><td>{{ player.total_playtime_sec | fmt_duration }}</td></tr>
|
||||
<tr><th>Since</th><td class="small">{{ player.first_seen | fmt_dt }}</td></tr>
|
||||
<tr><th>Last Seen</th><td class="small">{{ player.last_seen | fmt_dt }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-8">
|
||||
<ul class="nav nav-tabs mb-3" id="playerTabs">
|
||||
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-sessions">Sessions ({{ sessions|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-chat">Chat ({{ chat|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-cmds">Commands ({{ commands|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-deaths">Deaths ({{ deaths|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-tp">Teleports ({{ teleports|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-proxy">Proxy ({{ proxy_events|length }})</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tab-sessions">
|
||||
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Login</th><th>Logout</th><th>Duration</th><th>Server</th><th>IP</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in sessions %}<tr>
|
||||
<td class="small text-nowrap">{{ s.login_time | fmt_dt }}</td>
|
||||
<td class="small text-nowrap">{{ s.logout_time | fmt_dt }}</td>
|
||||
<td class="small">{{ s.duration_sec | fmt_duration }}</td>
|
||||
<td><span class="badge bg-secondary">{{ s.server_name or '—' }}</span></td>
|
||||
<td class="small text-muted">{{ s.ip_address or '—' }}</td>
|
||||
</tr>{% else %}<tr><td colspan="5" class="text-center text-muted">No sessions</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-chat">
|
||||
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Server</th><th>Message</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in chat %}<tr>
|
||||
<td class="small text-nowrap text-muted">{{ c.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-secondary">{{ c.server_name or '—' }}</span></td>
|
||||
<td class="small">{{ c.message }}</td>
|
||||
</tr>{% else %}<tr><td colspan="3" class="text-center text-muted">No chat messages</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-cmds">
|
||||
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Server</th><th>Command</th><th>Position</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in commands %}<tr>
|
||||
<td class="small text-nowrap text-muted">{{ c.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-secondary">{{ c.server_name or '—' }}</span></td>
|
||||
<td class="small font-monospace">{{ c.command }}</td>
|
||||
<td class="small text-muted">{{ c.world or '' }} {% if c.x %}({{ c.x|round(1) }}, {{ c.y|round(1) }}, {{ c.z|round(1) }}){% endif %}</td>
|
||||
</tr>{% else %}<tr><td colspan="4" class="text-center text-muted">No commands</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-deaths">
|
||||
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Cause</th><th>Killer</th><th>Level</th><th>World</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in deaths %}<tr>
|
||||
<td class="small text-nowrap text-muted">{{ d.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-danger">{{ d.cause or '—' }}</span></td>
|
||||
<td class="small">{{ d.killer_name or '—' }}</td>
|
||||
<td class="small">{{ d.exp_level }}</td>
|
||||
<td class="small text-muted">{{ d.world }}</td>
|
||||
</tr>{% else %}<tr><td colspan="5" class="text-center text-muted">No deaths</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-tp">
|
||||
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>From</th><th>To</th><th>Cause</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in teleports %}<tr>
|
||||
<td class="small text-nowrap text-muted">{{ t.timestamp | fmt_dt }}</td>
|
||||
<td class="small">{{ t.from_world }} ({{ t.from_x|round(0)|int }}, {{ t.from_y|round(0)|int }}, {{ t.from_z|round(0)|int }})</td>
|
||||
<td class="small">{{ t.to_world }} ({{ t.to_x|round(0)|int }}, {{ t.to_y|round(0)|int }}, {{ t.to_z|round(0)|int }})</td>
|
||||
<td><span class="badge bg-info text-dark">{{ t.cause or '—' }}</span></td>
|
||||
</tr>{% else %}<tr><td colspan="4" class="text-center text-muted">No teleports</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-proxy">
|
||||
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Type</th><th>From</th><th>To</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in proxy_events %}<tr>
|
||||
<td class="small text-nowrap text-muted">{{ e.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-primary">{{ e.event_type }}</span></td>
|
||||
<td class="small">{{ e.from_server or '—' }}</td>
|
||||
<td class="small">{{ e.to_server or '—' }}</td>
|
||||
</tr>{% else %}<tr><td colspan="4" class="text-center text-muted">No proxy events</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('panel.players') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Overview
|
||||
</a>
|
||||
{% endblock %}
|
||||
56
web/templates/panel/players.html
Normal file
56
web/templates/panel/players.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Players{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-people-fill me-2"></i>Players{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 mb-3">
|
||||
<div class="col-auto flex-grow-1">
|
||||
<input type="text" name="q" class="form-control" placeholder="Search by player name…" value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success">Search</button>
|
||||
{% if search %}<a href="{{ url_for('panel.players') }}" class="btn btn-outline-secondary ms-1">Reset</a>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>{{ total }} players found</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Player</th><th>IP</th><th>First Seen</th><th>Last Seen</th><th>Playtime</th><th>OP</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in players %}
|
||||
<tr>
|
||||
<td class="fw-semibold">
|
||||
<i class="bi bi-person-circle me-1 text-success"></i>{{ p.username }}
|
||||
</td>
|
||||
<td class="small text-muted">{{ p.ip_address or '—' }}</td>
|
||||
<td class="small text-muted text-nowrap">{{ p.first_seen | fmt_dt }}</td>
|
||||
<td class="small text-muted text-nowrap">{{ p.last_seen | fmt_dt }}</td>
|
||||
<td class="small">{{ p.total_playtime_sec | fmt_duration }}</td>
|
||||
<td>
|
||||
{% if p.is_op %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-shield-fill"></i> OP</span>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('panel.player_detail', uuid=p.uuid) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">No players found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user