From f2f9e02fb2a8402f081bcae1233ef2d65f4e51f6 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Fri, 9 Jan 2026 08:43:18 +0100 Subject: [PATCH] zustand auf server wiederhergestellt --- HANDOFF-SATISFACTORY.md | 69 + auth.js | 30 + docs/gitea-setup.md | 417 +++ gsm-backend/config.json | 95 + gsm-backend/config.tmp | 0 gsm-backend/db/database.sqlite | 0 gsm-backend/db/init.js | 294 ++ gsm-backend/db/users.sqlite | Bin 0 -> 77824 bytes gsm-backend/middleware/auth.js | 57 + gsm-backend/package-lock.json | 2461 +++++++++++++++++ gsm-backend/package.json | 22 + gsm-backend/routes/auth.js | 244 ++ gsm-backend/routes/servers.js | 73 +- gsm-backend/routes/servers.js.bak | 679 +++++ gsm-backend/server.js | 37 + gsm-backend/services/autoshutdown.js | 113 + gsm-backend/services/discord.js | 167 ++ gsm-backend/services/discordBot.js | 19 +- gsm-backend/services/factorio.js | 99 + gsm-backend/services/prometheus.js | 112 + gsm-backend/services/ssh.js | 101 +- gsm-backend/services/ssh.js.bak | 493 ++++ gsm-backend/services/ssh.js.bak2 | 493 ++++ gsm-frontend/package-lock.json | 18 +- gsm-frontend/src/App.jsx | 10 +- gsm-frontend/src/components/LoginModal.jsx | 82 +- .../src/components/UserManagement.jsx | 66 +- gsm-frontend/src/pages/AuthCallback.jsx | 28 + gsm-frontend/src/pages/Dashboard.jsx | 123 +- gsm-frontend/src/pages/LoginPage.jsx | 140 + 30 files changed, 6403 insertions(+), 139 deletions(-) create mode 100644 HANDOFF-SATISFACTORY.md create mode 100644 docs/gitea-setup.md create mode 100644 gsm-backend/config.json create mode 100644 gsm-backend/config.tmp create mode 100644 gsm-backend/db/database.sqlite create mode 100644 gsm-backend/db/init.js create mode 100644 gsm-backend/db/users.sqlite create mode 100644 gsm-backend/middleware/auth.js create mode 100644 gsm-backend/package-lock.json create mode 100644 gsm-backend/package.json create mode 100644 gsm-backend/routes/auth.js create mode 100644 gsm-backend/routes/servers.js.bak create mode 100644 gsm-backend/server.js create mode 100644 gsm-backend/services/autoshutdown.js create mode 100644 gsm-backend/services/discord.js create mode 100644 gsm-backend/services/factorio.js create mode 100644 gsm-backend/services/prometheus.js create mode 100644 gsm-backend/services/ssh.js.bak create mode 100644 gsm-backend/services/ssh.js.bak2 create mode 100644 gsm-frontend/src/pages/AuthCallback.jsx create mode 100644 gsm-frontend/src/pages/LoginPage.jsx diff --git a/HANDOFF-SATISFACTORY.md b/HANDOFF-SATISFACTORY.md new file mode 100644 index 0000000..afe9787 --- /dev/null +++ b/HANDOFF-SATISFACTORY.md @@ -0,0 +1,69 @@ +# Handoff: Satisfactory Server Setup + +**Datum**: 2026-01-08 +**Status**: Windows VM Installation blockiert + +## Aktueller Stand + +### Was passiert ist +1. Linux LXC mit Docker für Satisfactory aufgesetzt +2. Festgestellt: **SatisfactoryPlus Mod ist nur für Windows** verfügbar (kein LinuxServer Build) +3. LXC 105 wieder gelöscht +4. Windows Server 2022 VM Erstellung gestartet +5. **Problem**: Windows Installer findet keine Festplatte (VirtIO Treiber werden nicht erkannt) + +### Das Problem +- Windows Installer zeigt "no signed device drivers were found" +- VirtIO ISO eingebunden, aber Treiber werden nicht akzeptiert +- Vermutlich Secure Boot oder falscher Controller-Typ + +## Nächste Schritte morgen + +### 1. VM-Konfiguration prüfen +```bash +ssh root@192.168.2.20 "qm config " +``` +- Welche VM-ID wurde verwendet? (nicht mehr 105) +- Prüfen: BIOS-Typ (SeaBIOS vs OVMF/UEFI) +- Prüfen: Secure Boot aktiviert? + +### 2. Lösungsoptionen + +**Option A: SeaBIOS statt UEFI** +- VM → Options → BIOS → SeaBIOS +- Kein Secure Boot Problem + +**Option B: IDE Controller verwenden** +- Hard Disk detachen +- Neu hinzufügen mit Bus: IDE +- Langsamer aber funktioniert ohne Treiber + +**Option C: VirtIO mit korrektem Treiber** +- Neueste VirtIO ISO: https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso +- Im Installer: Treiber laden → `viostor\2k22\amd64` (nicht vioscsi!) + +### 3. Nach Windows Installation +- [ ] VirtIO Guest Tools installieren (`virtio-win-guest-tools.exe`) +- [ ] QEMU Guest Agent aktivieren +- [ ] Statische IP 192.168.2.54 setzen +- [ ] OpenSSH Server installieren (für GSM) +- [ ] Node Exporter für Windows installieren +- [ ] Satisfactory Dedicated Server installieren (Steam) +- [ ] Satisfactory Mod Manager (SMM) installieren +- [ ] SatisfactoryPlus Mod installieren +- [ ] GSM config.json erweitern +- [ ] Firewall Ports öffnen (7777, 15777, 15000) + +## VM Empfohlene Specs +| Setting | Wert | +|---------|------| +| OS | Windows Server 2022 mit GUI | +| CPU | 4-6 vCPU | +| RAM | 20-24 GB | +| Disk | 100 GB | +| IP | 192.168.2.54 | +| BIOS | SeaBIOS (einfacher) | + +## Relevante Dateien +- GSM Config: `/opt/gameserver-monitor/backend/config.json` (auf 192.168.2.30) +- Doku: `docs/satisfactory.md` (muss neu erstellt werden nach Setup) diff --git a/auth.js b/auth.js index 968fa42..49d8612 100644 --- a/auth.js +++ b/auth.js @@ -9,6 +9,26 @@ const router = Router(); // Initialize Discord users table initDiscordUsers(); +// ===== Guest Login ===== + +// Create guest token (view-only, expires in 24h) +router.post('/guest', (req, res) => { + const guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9); + + const token = jwt.sign( + { + id: guestId, + username: 'Gast', + role: 'guest', + isGuest: true + }, + process.env.JWT_SECRET, + { expiresIn: '24h' } + ); + + res.json({ token }); +}); + // ===== Discord OAuth2 ===== // Start Discord OAuth2 flow @@ -99,6 +119,16 @@ router.get('/discord/callback', async (req, res) => { // Get current user info router.get('/me', authenticateToken, (req, res) => { + // Check if it's a guest user + if (req.user.isGuest) { + return res.json({ + id: req.user.id, + username: req.user.username, + role: req.user.role, + isGuest: true + }); + } + // Check if it's a Discord user if (req.user.discordId) { const user = db.prepare('SELECT id, discord_id, username, avatar, role FROM discord_users WHERE id = ?').get(req.user.id); diff --git a/docs/gitea-setup.md b/docs/gitea-setup.md new file mode 100644 index 0000000..060f42f --- /dev/null +++ b/docs/gitea-setup.md @@ -0,0 +1,417 @@ +# Gitea Setup mit CI/CD Runner + +Lokale Git-Instanz auf Proxmox LXC mit automatischem Deployment via Gitea Actions. + +## Übersicht + +``` +┌─────────────────┐ git push ┌─────────────────┐ +│ Lokaler PC │ ───────────────▶ │ Gitea LXC │ +│ (Development) │ │ 192.168.2.40 │ +└─────────────────┘ └────────┬────────┘ + │ trigger + ▼ + ┌─────────────────┐ + │ Gitea Runner │ + │ (act_runner) │ + └────────┬────────┘ + │ SSH deploy + ▼ + ┌─────────────────┐ + │ GSM Server │ + │ 192.168.2.30 │ + └─────────────────┘ +``` + +## Teil 1: LXC Container erstellen + +### Proxmox Web UI +1. CT Template herunterladen: `Datacenter → pve → local → CT Templates → Templates` + - Debian 12 (Bookworm) empfohlen +2. Neuen Container erstellen: + - **CT ID:** 104 (oder nächste freie) + - **Hostname:** gitea + - **Password:** sicheres Root-Passwort + - **Template:** debian-12-standard + - **Disk:** 16 GB + - **CPU:** 2 Cores + - **RAM:** 1024 MB + - **Network:** vmbr0, DHCP oder statisch 192.168.2.40 + +### Oder per CLI auf Proxmox Host +```bash +# Template herunterladen falls nicht vorhanden +pveam download local debian-12-standard_12.2-1_amd64.tar.zst + +# Container erstellen +pct create 104 local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst \ + --hostname gitea \ + --memory 1024 \ + --cores 2 \ + --rootfs local-lvm:16 \ + --net0 name=eth0,bridge=vmbr0,ip=192.168.2.40/24,gw=192.168.2.1 \ + --password \ + --unprivileged 1 \ + --features nesting=1 + +# Container starten +pct start 104 +``` + +## Teil 2: Gitea Installation + +### System vorbereiten +```bash +# In den Container einloggen +pct enter 104 + +# System updaten +apt update && apt upgrade -y + +# Abhängigkeiten installieren +apt install -y git curl wget sudo sqlite3 +``` + +### Git-User erstellen +```bash +adduser --system --shell /bin/bash --group --disabled-password --home /home/git git +``` + +### Gitea herunterladen +```bash +# Aktuelle Version prüfen: https://github.com/go-gitea/gitea/releases +GITEA_VERSION="1.21.4" + +wget -O /usr/local/bin/gitea https://dl.gitea.io/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64 +chmod +x /usr/local/bin/gitea + +# Version prüfen +gitea --version +``` + +### Verzeichnisse erstellen +```bash +mkdir -p /var/lib/gitea/{custom,data,log} +mkdir -p /etc/gitea +chown -R git:git /var/lib/gitea +chown root:git /etc/gitea +chmod 770 /etc/gitea +``` + +### Systemd Service +```bash +cat > /etc/systemd/system/gitea.service << 'EOF' +[Unit] +Description=Gitea (Git with a cup of tea) +After=network.target + +[Service] +RestartSec=2s +Type=simple +User=git +Group=git +WorkingDirectory=/var/lib/gitea/ +ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini +Restart=always +Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable gitea +systemctl start gitea +``` + +### Web-Setup abschließen +1. Browser öffnen: `http://192.168.2.40:3000` +2. Initial-Setup: + - **Database:** SQLite3 + - **SSH Port:** 22 + - **HTTP Port:** 3000 + - **Base URL:** `http://192.168.2.40:3000/` (später ändern für Domain) + - **Admin Account erstellen** + +### Berechtigungen nach Setup fixieren +```bash +chmod 750 /etc/gitea +chmod 640 /etc/gitea/app.ini +``` + +## Teil 3: Gitea Actions aktivieren + +### app.ini anpassen +```bash +nano /etc/gitea/app.ini +``` + +Folgende Sektion hinzufügen/anpassen: +```ini +[actions] +ENABLED = true +DEFAULT_ACTIONS_URL = github +``` + +Gitea neustarten: +```bash +systemctl restart gitea +``` + +## Teil 4: Gitea Actions Runner + +### Runner installieren +```bash +# Als root auf dem Gitea-Server (oder separater Server) +cd /opt +RUNNER_VERSION="0.2.6" +wget https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-amd64 +mv act_runner-${RUNNER_VERSION}-linux-amd64 act_runner +chmod +x act_runner +``` + +### Runner Token generieren +1. Gitea Web UI → `Site Administration → Actions → Runners` +2. `Create new Runner` → Token kopieren + +### Runner registrieren +```bash +cd /opt +./act_runner register --no-interactive \ + --instance http://192.168.2.40:3000 \ + --token \ + --name homelab-runner \ + --labels ubuntu-latest:docker://node:20-bookworm,ubuntu-22.04:docker://ubuntu:22.04 +``` + +### Runner als Systemd Service +```bash +cat > /etc/systemd/system/gitea-runner.service << 'EOF' +[Unit] +Description=Gitea Actions Runner +After=network.target gitea.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt +ExecStart=/opt/act_runner daemon +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable gitea-runner +systemctl start gitea-runner +``` + +### Docker für Runner installieren (falls Labels mit docker:// genutzt) +```bash +apt install -y docker.io +systemctl enable docker +systemctl start docker +``` + +## Teil 5: SSH Deploy Key einrichten + +### Auf dem Gitea-Server (Runner) +```bash +# SSH Key für Deployments erstellen +ssh-keygen -t ed25519 -C "gitea-deploy" -f /root/.ssh/deploy_key -N "" + +# Public Key anzeigen +cat /root/.ssh/deploy_key.pub +``` + +### Auf dem GSM-Server (192.168.2.30) +```bash +# Public Key zu authorized_keys hinzufügen +echo "ssh-ed25519 AAAA... gitea-deploy" >> /root/.ssh/authorized_keys +``` + +### In Gitea als Secret speichern +1. Repository → `Settings → Actions → Secrets` +2. Neues Secret: `SSH_DEPLOY_KEY` +3. Inhalt: Private Key (`cat /root/.ssh/deploy_key`) + +## Teil 6: GSM Repository einrichten + +### Auf dem GSM-Server (192.168.2.30) +```bash +cd /opt/gameserver-monitor + +# Falls noch kein Git-Repo +git init +git add . +git commit -m "Initial commit" + +# Gitea als Remote hinzufügen +git remote add origin http://192.168.2.40:3000//gameserver-monitor.git +git push -u origin main +``` + +### Workflow-Datei erstellen +Im Repository `.gitea/workflows/deploy.yml` erstellen: + +```yaml +name: Deploy GSM + +on: + push: + branches: [main] + workflow_dispatch: # Manueller Trigger + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Deploy to Server + uses: appleboy/ssh-action@v1.0.3 + with: + host: 192.168.2.30 + username: root + key: ${{ secrets.SSH_DEPLOY_KEY }} + script: | + set -e + cd /opt/gameserver-monitor + + echo "=== Pulling latest changes ===" + git fetch origin main + git reset --hard origin/main + + echo "=== Installing backend dependencies ===" + cd backend + npm ci --production + + echo "=== Building frontend ===" + cd ../frontend + npm ci + npm run build + + echo "=== Restarting services ===" + pm2 restart gsm-backend + + echo "=== Deploy complete ===" +``` + +### Alternative: Separater Frontend/Backend Deploy +```yaml +name: Deploy GSM + +on: + push: + branches: [main] + paths: + - 'backend/**' + - 'frontend/**' + +jobs: + deploy-backend: + runs-on: ubuntu-latest + if: contains(github.event.head_commit.modified, 'backend/') + steps: + - uses: actions/checkout@v4 + - name: Deploy Backend + uses: appleboy/ssh-action@v1.0.3 + with: + host: 192.168.2.30 + username: root + key: ${{ secrets.SSH_DEPLOY_KEY }} + script: | + cd /opt/gameserver-monitor/backend + git pull origin main + npm ci --production + pm2 restart gsm-backend + + deploy-frontend: + runs-on: ubuntu-latest + if: contains(github.event.head_commit.modified, 'frontend/') + steps: + - uses: actions/checkout@v4 + - name: Deploy Frontend + uses: appleboy/ssh-action@v1.0.3 + with: + host: 192.168.2.30 + username: root + key: ${{ secrets.SSH_DEPLOY_KEY }} + script: | + cd /opt/gameserver-monitor/frontend + git pull origin main + npm ci + npm run build +``` + +## Teil 7: Reverse Proxy (Optional) + +### Nginx Proxy Manager Konfiguration +Falls Gitea über Domain erreichbar sein soll (z.B. `git.zeasy.dev`): + +1. Nginx Proxy Manager → Proxy Hosts → Add +2. **Domain:** git.zeasy.dev +3. **Forward Hostname:** 192.168.2.40 +4. **Forward Port:** 3000 +5. **SSL:** Let's Encrypt aktivieren + +### Gitea app.ini anpassen +```ini +[server] +DOMAIN = git.zeasy.dev +ROOT_URL = https://git.zeasy.dev/ +SSH_DOMAIN = git.zeasy.dev +``` + +## Teil 8: Lokale Git-Konfiguration + +### Remote für lokales Entwickeln +```bash +# Im lokalen Projekt +cd E:/Projects/homelab-docs/gsm-frontend +git remote add gitea http://192.168.2.40:3000//gameserver-monitor.git + +# Oder mit SSH (wenn SSH-Key eingerichtet) +git remote add gitea git@192.168.2.40:/gameserver-monitor.git +``` + +### Workflow +```bash +# Entwickeln... +git add . +git commit -m "Feature: xyz" +git push gitea main # → Triggert automatisch Deploy +``` + +## Troubleshooting + +### Runner-Status prüfen +```bash +systemctl status gitea-runner +journalctl -u gitea-runner -f +``` + +### Gitea Logs +```bash +journalctl -u gitea -f +# oder +tail -f /var/lib/gitea/log/gitea.log +``` + +### Actions Debug +- In Gitea Web UI: Repository → Actions → Job auswählen → Logs ansehen + +### SSH-Verbindung testen +```bash +# Vom Runner aus +ssh -i /root/.ssh/deploy_key root@192.168.2.30 "echo 'Connection OK'" +``` + +## Ressourcen + +- Gitea Docs: https://docs.gitea.io/ +- Gitea Actions: https://docs.gitea.io/en-us/actions/overview/ +- Act Runner: https://gitea.com/gitea/act_runner diff --git a/gsm-backend/config.json b/gsm-backend/config.json new file mode 100644 index 0000000..c918e8e --- /dev/null +++ b/gsm-backend/config.json @@ -0,0 +1,95 @@ +{ + "ramBudget": 30, + "servers": [ + { + "id": "minecraft", + "name": "All the Mods 10 | Minecraft", + "host": "192.168.2.51", + "type": "minecraft", + "runtime": "screen", + "maxRam": 12, + "rconPort": 25575, + "rconPassword": "gsm-mc-2026", + "screenName": "minecraft", + "workDir": "/opt/minecraft", + "startCmd": "./run.sh" + }, + { + "id": "factorio", + "name": "Factorio", + "host": "192.168.2.50", + "type": "factorio", + "runtime": "docker", + "maxRam": 4, + "containerName": "factorio", + "rconPort": 27015, + "rconPassword": "jieTig6IkixaKuu" + }, + { + "id": "vrising", + "name": "V Rising", + "host": "192.168.2.52", + "type": "vrising", + "runtime": "systemd", + "maxRam": 12, + "serviceName": "vrising", + "rconPort": 25575, + "rconPassword": "changeme", + "workDir": "/home/steam/vrising" + }, + { + "id": "zomboid", + "name": "Project Zomboid", + "host": "10.0.30.66", + "type": "zomboid", + "runtime": "screen", + "external": true, + "rconPort": 27015, + "rconPassword": "ShkeloAufNettoParkplatzSchlagen47139", + "screenName": "zomboid", + "workDir": "/opt/pzserver", + "startCmd": "./start-server.sh -servername Project", + "sshUser": "pzuser", + "logFile": "/home/pzuser/Zomboid/server-console.txt" + }, + { + "id": "palworld", + "name": "Palworld", + "host": "192.168.2.53", + "type": "palworld", + "runtime": "systemd", + "maxRam": 12, + "serviceName": "palworld", + "rconPort": 25575, + "rconPassword": "gsm-pal-admin-2026", + "restApiPort": 8212, + "workDir": "/opt/palworld", + "configPath": "/opt/palworld/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini" + }, + { + "id": "terraria", + "name": "Terraria", + "host": "10.0.30.202", + "type": "terraria", + "runtime": "pm2", + "external": true, + "serviceName": "terraria", + "sshUser": "terraria", + "workDir": "/home/terraria/1449/Linux", + "configPath": "/home/terraria/serverconfig.txt", + "port": 7777 + }, + { + "id": "openttd", + "name": "OpenTTD", + "host": "10.0.30.203", + "type": "openttd", + "runtime": "systemd", + "external": true, + "serviceName": "openttd", + "sshUser": "openttd", + "workDir": "/opt/openttd", + "port": 3979 + } + ] +} diff --git a/gsm-backend/config.tmp b/gsm-backend/config.tmp new file mode 100644 index 0000000..e69de29 diff --git a/gsm-backend/db/database.sqlite b/gsm-backend/db/database.sqlite new file mode 100644 index 0000000..e69de29 diff --git a/gsm-backend/db/init.js b/gsm-backend/db/init.js new file mode 100644 index 0000000..3daff3e --- /dev/null +++ b/gsm-backend/db/init.js @@ -0,0 +1,294 @@ +import Database from 'better-sqlite3'; +import bcrypt from 'bcrypt'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const db = new Database(join(__dirname, 'users.sqlite')); + +const VALID_ROLES = ['user', 'moderator', 'superadmin']; + +export function initDb() { + // Create users table with role + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + role TEXT DEFAULT 'user' CHECK(role IN ('user', 'moderator', 'superadmin')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Migration: add role column if it doesn't exist + const columns = db.prepare("PRAGMA table_info(users)").all(); + const hasRole = columns.some(col => col.name === 'role'); + + if (!hasRole) { + db.exec("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'"); + // Upgrade existing admin user to superadmin + db.prepare("UPDATE users SET role = 'superadmin' WHERE username = 'admin'").run(); + console.log('Migration: Added role column, admin upgraded to superadmin'); + } + + // Create default admin if no users exist + const count = db.prepare('SELECT COUNT(*) as count FROM users').get(); + if (count.count === 0) { + const hash = bcrypt.hashSync('admin', 10); + db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('admin', hash, 'superadmin'); + console.log('Default superadmin user created (username: admin, password: admin)'); + } +} + +export { db, VALID_ROLES }; + +// Whitelist cache table +export function initWhitelistCache() { + db.exec(` + CREATE TABLE IF NOT EXISTS whitelist_cache ( + server_id TEXT PRIMARY KEY, + players TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); +} + +export function getCachedWhitelist(serverId) { + const row = db.prepare('SELECT players FROM whitelist_cache WHERE server_id = ?').get(serverId); + return row ? JSON.parse(row.players) : []; +} + +export function setCachedWhitelist(serverId, players) { + db.prepare(` + INSERT OR REPLACE INTO whitelist_cache (server_id, players, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + `).run(serverId, JSON.stringify(players)); +} + +// Factorio templates table +export function initFactorioTemplates() { + db.exec(` + CREATE TABLE IF NOT EXISTS factorio_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + settings TEXT NOT NULL, + created_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES users(id) + ) + `); +} + +export function getFactorioTemplates() { + return db.prepare(` + SELECT t.id, t.name, t.settings, t.created_at, u.username as created_by_name + FROM factorio_templates t + LEFT JOIN users u ON t.created_by = u.id + ORDER BY t.name + `).all(); +} + +export function getFactorioTemplate(id) { + return db.prepare("SELECT * FROM factorio_templates WHERE id = ?").get(id); +} + +export function createFactorioTemplate(name, settings, userId) { + const result = db.prepare( + "INSERT INTO factorio_templates (name, settings, created_by) VALUES (?, ?, ?)" + ).run(name, JSON.stringify(settings), userId); + return result.lastInsertRowid; +} + +export function deleteFactorioTemplate(id) { + return db.prepare("DELETE FROM factorio_templates WHERE id = ?").run(id); +} + +// Factorio world settings table (stores settings used when creating worlds) +export function initFactorioWorldSettings() { + db.exec(` + CREATE TABLE IF NOT EXISTS factorio_world_settings ( + save_name TEXT PRIMARY KEY, + settings TEXT NOT NULL, + created_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES users(id) + ) + `); +} + +export function getFactorioWorldSettings(saveName) { + return db.prepare( + "SELECT ws.*, u.username as created_by_name FROM factorio_world_settings ws LEFT JOIN users u ON ws.created_by = u.id WHERE ws.save_name = ?" + ).get(saveName); +} + +export function saveFactorioWorldSettings(saveName, settings, userId) { + return db.prepare( + "INSERT OR REPLACE INTO factorio_world_settings (save_name, settings, created_by, created_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)" + ).run(saveName, JSON.stringify(settings), userId); +} + +export function deleteFactorioWorldSettings(saveName) { + return db.prepare("DELETE FROM factorio_world_settings WHERE save_name = ?").run(saveName); +} + +// Auto-shutdown settings table +export function initAutoShutdownSettings() { + db.exec(` + CREATE TABLE IF NOT EXISTS autoshutdown_settings ( + server_id TEXT PRIMARY KEY, + enabled INTEGER DEFAULT 0, + timeout_minutes INTEGER DEFAULT 15, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); +} + +export function getAutoShutdownSettings(serverId) { + return db.prepare('SELECT * FROM autoshutdown_settings WHERE server_id = ?').get(serverId); +} + +export function getAllAutoShutdownSettings() { + return db.prepare('SELECT * FROM autoshutdown_settings WHERE enabled = 1').all(); +} + +export function setAutoShutdownSettings(serverId, enabled, timeoutMinutes) { + return db.prepare(` + INSERT OR REPLACE INTO autoshutdown_settings (server_id, enabled, timeout_minutes, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + `).run(serverId, enabled ? 1 : 0, timeoutMinutes); +} + +// Discord users table +export function initDiscordUsers() { + db.exec(` + CREATE TABLE IF NOT EXISTS discord_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discord_id TEXT UNIQUE NOT NULL, + username TEXT NOT NULL, + discriminator TEXT DEFAULT '0', + avatar TEXT, + role TEXT DEFAULT 'user' CHECK(role IN ('user', 'moderator', 'superadmin')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); +} + +// Activity Log +export function initActivityLog() { + db.exec(` + CREATE TABLE IF NOT EXISTS activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + username TEXT NOT NULL, + discord_id TEXT, + avatar TEXT, + action TEXT NOT NULL, + target TEXT, + details TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); +} + +export function logActivity(userId, username, action, target = null, details = null, discordId = null, avatar = null) { + db.prepare(` + INSERT INTO activity_log (user_id, username, discord_id, avatar, action, target, details) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(userId, username, discordId, avatar, action, target, details); +} + +export function getActivityLog(limit = 100) { + return db.prepare(` + SELECT * FROM activity_log ORDER BY created_at DESC LIMIT ? + `).all(limit); +} + +// Server display settings table +export function initServerDisplaySettings() { + db.exec(` + CREATE TABLE IF NOT EXISTS server_display_settings ( + server_id TEXT PRIMARY KEY, + address TEXT, + hint TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); +} + +export function getServerDisplaySettings(serverId) { + return db.prepare('SELECT * FROM server_display_settings WHERE server_id = ?').get(serverId); +} + +export function getAllServerDisplaySettings() { + return db.prepare('SELECT * FROM server_display_settings').all(); +} + +export function setServerDisplaySettings(serverId, address, hint) { + return db.prepare(` + INSERT OR REPLACE INTO server_display_settings (server_id, address, hint, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + `).run(serverId, address, hint); +} + +// Guild settings for multi-server Discord bot +export function initGuildSettings() { + db.exec(` + CREATE TABLE IF NOT EXISTS guild_settings ( + guild_id TEXT PRIMARY KEY, + category_id TEXT, + info_channel_id TEXT, + status_channel_id TEXT, + status_message_id TEXT, + alerts_channel_id TEXT, + updates_channel_id TEXT, + discussion_channel_id TEXT, + requests_channel_id TEXT, + requests_info_thread_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); +} + +export function getGuildSettings(guildId) { + return db.prepare('SELECT * FROM guild_settings WHERE guild_id = ?').get(guildId); +} + +export function getAllGuildSettings() { + return db.prepare('SELECT * FROM guild_settings').all(); +} + +export function setGuildSettings(guildId, settings) { + const existing = getGuildSettings(guildId); + if (existing) { + return db.prepare(` + UPDATE guild_settings SET + category_id = ?, info_channel_id = ?, status_channel_id = ?, status_message_id = ?, + alerts_channel_id = ?, updates_channel_id = ?, discussion_channel_id = ?, + requests_channel_id = ?, requests_info_thread_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE guild_id = ? + `).run( + settings.category_id, settings.info_channel_id, settings.status_channel_id, + settings.status_message_id, settings.alerts_channel_id, settings.updates_channel_id, + settings.discussion_channel_id, settings.requests_channel_id, settings.requests_info_thread_id, + guildId + ); + } else { + return db.prepare(` + INSERT INTO guild_settings (guild_id, category_id, info_channel_id, status_channel_id, + status_message_id, alerts_channel_id, updates_channel_id, discussion_channel_id, + requests_channel_id, requests_info_thread_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + guildId, settings.category_id, settings.info_channel_id, settings.status_channel_id, + settings.status_message_id, settings.alerts_channel_id, settings.updates_channel_id, + settings.discussion_channel_id, settings.requests_channel_id, settings.requests_info_thread_id + ); + } +} + +export function deleteGuildSettings(guildId) { + return db.prepare('DELETE FROM guild_settings WHERE guild_id = ?').run(guildId); +} diff --git a/gsm-backend/db/users.sqlite b/gsm-backend/db/users.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..8e6412412347fbbe9cf31ab7f23521b7d120923c GIT binary patch literal 77824 zcmeI5TWs4{ddDTpwtSPBOg5uVQ?D7%1dca}%{wV&7Ffqo94Ee8N$d=!DkUCTj3rVg zDLc+!xAn~K6vd)_Efz%|rr5#kLkkqxMT-Ip^oI7K?Jf#TQS=V9K-)!8^r5rc_O1O$ ziL|astj+*w`f)OrczAgJ=lsr%`i{=0t&L^FCA4nYW!0tm$eBnq8u=1UM)13&2h&cHYNzTSV>Eg=kl=?T1NoQ)@EW}-ivaMgT?>{gAEmUD>f8s@IkeD=!1 zW@bK{p|kThmNRtoHTv=h?H{#ZL!+~q?JT{%xwJCBxkKO0>|Cu!D5x&kwd|u@Jx&mA zn7WlK6jjqCrPgbXtGZR^8CT21anxPXdaqg{w%hgIYDI(mbiJ$@PNC{JhGljWx5<8$ zINb%p>wfv%qD@q-HH!rZbcvROvEU{E&MvKF=$n~a^Y@ms^uoQ(&CF^x=LK$M=U3JP zUbUP*=S@EQnd`3%Cf8=75yRBTV`skvV^B`5x|V;QYp&27+kAfRI8Vis*RMz4eamks z4zUl2odZCXl6n-jm@dI*nwn0RNMReQUw=v?QZ>zn>h>F10HkP`Zc{)HgjUWBCe`cR z2zAMKj_q>c?7MG{$CH;XMZbF8XQmch_gEv>@sE6_#)5LL*I*Tm{KGzOb$nbdmb>ROgwy|=s^T=i5fv-34qwRcG~TaCD? zQEIM$Q#E71MP8-74rbXWJ%UybRFL!YG4tNsSUfpB9esbo#}~x)%I7qlB$@y=UFwNo zkV4P`@2xIv+{@6d%)E?rC+a0)8)d^(U8`20uv1Sn(;@eh@&-Y+RjOA~FbGV0O4Ibh z?aad6%l_r1Rr+!*^eR1FwlrdUD${V{R4Z_zf$sFoOpxTMswTLphyL?!-$*#rP0CU+j6%-q+{7~Y{%fScfT;?54!K|`8;*#rIu}2xkJlt zn5Me~4>vLG66u7hdO&g~t+@l$;HjYVAQ3dN{82zNKp0#()%132Z8Nj9xavJQ(3gW0 z@<%iDX69A~X2gZe7VQrNmkn*EHodJ3KW*^tQvMv<<-*9jw+7=$mW_T@Zf5V2GE8~U zYQh`Cka%a};q?&q$>{v-;rygdx(q`vdc7Rq=#M9_T#3G0n*|Sx@Z3={95+``3q{g$ zI?#l$v|POG73wP7 zIg?5ZCRe7rjf=sY>l4heO{e43S0Y2+N3n(YVDbUmEs6FH+0eVgPB=vUK`pT_4h$wY zu5?SRrRn9^mViO(ziP?M^bIDznCzBJy*U0$3`*b^Ba!%}$k13mLVbhc#{YOMpZMPA(buT<~ZN88nhbMrep@?xkX(TtK7*fg684V%SyJTeKznOrBP;gn&KsIFAV z&q?IQzVS%k(HGoDYj?DV2e;OoVrh%Nv%0u_`)Kp7xw5wHsHN@loVZw;&)hhyhDsvM zGHF3db5f`Tb)lW}l$MEA*I3h**XFV=dzajI=I`uhj*cMt*&Rt-G}(nkGjnHEykm;; ztexM>JX}85U)<&wjRp-U4g6!7P@^yqkDT{3ZmNg7brEUqLEf3)UQI19N20y4ZY(YA z&(3a1hwD1O@leU!+TS2cH*Z!CA6MtNOnGBXnbS$52m`#*EE|fDk$TN6704}SyDnqh zaU6d8etu!^&LMkb9^|vqR&{n`cYAJyDVgOPl{wMT?o}S&JP?)2^5fLnZT;a1FNtQk zG&BNUIgyFgWo~R#!LC!6VTF83%9?x9oh@;X-8#6xRhUmz_ttMbvD}@T+SZy|dMIRf zY~gXXy83W=V{c*I-3m(s4NXk*e3}o&dtyLbQjKCyySlm53tTU&>Y=-7W6eILvzv@~Z)?V_;Y4gQ8XBPDaPJTpi3W#a=cAq>XTXbDGq?47Z)*mTnD@43 zwCEd(C5L^(LIUoMQGXhtenkBPyx@TZkN^@u0!RP}AOR$R1dsp{Kmter3H%=*a3&VJ z6m8tMId!%#HXUu+!!R@)jU}W0K7bML^M923y$Jln0|_7jB!C2v01`j~NB{{S0VIF~ zkN^^RVF|oB)IXWw!F>!V*sAWnHC0>fpSn6VUv?cqhBviE|Eagbod6=u2x*QinX2_OL^fCP{L5kAkkN^@u0!RP}AOR$R1dsp{Kmter z2>AQ|6Xgi}!vhH*0VIF~kN^@u0!RP}AOR$R1U@YSUpo^WocuXsm_Hycu~n0p?@YlP zTQ$s_Lw3u=bXC`|%&9bcb?Q)sOSN}~<`$>Y%+;wPF?Nft|JHF;+cnJHoN5!*k5(aS zf#}sz&ikplskE+^9CCF^t-4mF1Xpqe%XDq4aCnJQRn$$QmpvR@@;;mA+HF`mGU zplut4qDdTx%6=KPkGqx$;m;YnU{xx_zGm6ae~V#T=BaL|0z9KyKGi)1yQ-0Emht@M zc5Om@TF*s^n52AkEw4JyTW%m*C>gpARquK==}Vbv8SbxLfSvob2&6;~{DzTZphln_ zv<4d{w3P#|N(H#uQhS7GQ)#nWDt);G};TM_Dq z)IU+*qJEe94)s^m_g~&B#*!fcB!C2v01`j~NB{{S0VIF~kN^^RkqL~(E=22giL+y| ziD+PfIOClJwuWIZR^6(Q@ZQ($2eBrf|9zveiQ&K|;4SqJjqOFKU!W$&e|`MT#BV3o z#{LEZ@IV4c00|%gB!C2v01`j~NB{}+PQV%(j z6_!uK^ko|KSXk-hn}ax5l}(w0wK9Svmtt66U{fp$B~(&8$KSFZGn_0Eja5`dPvse& z*HlhoMOo7eI;$~?Ds<9=Wn+#@b8LfN5cJLf=v}EX6%;|_1rbW9Fd{3nJZ$2p58;nX z=MC7M&ags(5p`KmHC`!b1)?yrlHznm0Jxe+*baPaYt~$vIpKQ}WcP#kC2Hk7&x@iY zD7*v(W_Vc^QYra%)p3cbn&5_@#4rNKsZd8;UMvtzBa#k+G9b|uo)c7|>X1gjjGXqC z%B6+^2SIx=AMgff@;s|BipcS@0OjR4QCcPzqvrCdl%gpNM+8ArRGC*4XhbS2vU#28 zAl)kF)f=qKH;UDRtUeGs?>8V$5;<0p1iwmUPEc6WC|C?rNC|meQSt>*A$(p`IgnEd zsT9lTy3EU+s*_>TqSPW-52{3Kg3v`|E~Q9(N>o@`g66|9;(UobW+YiqNM6>6&gV5o zlQltxt|Y?Wl%x_}=~#!L$n$Ba1csBYG3+%4x(zJ;Gth>;_5UGi$@~1jM14#hQa?p~ zm->+Um)^TM-iZW|01`j~NB{{S0VIF~kN^@u0!ZK`Ch%&EjkcS$hP>c*<5S#^-DV&f z^n=^%I0IgAyM?9SPrJo;lCI*hX;p#c}R$V7(YMw{@~{aer4cu{lC_)#C|{aR^Jc%ex@%G z{buxf81nr;ZwJF=f^#rnG3Ji+j~82 z&)=(V?AFifFS7-Gx214m8qDyg7k%x2DJt~PKFN=rrsAM+fQ7RMnd8x}5SLv)#CRWu=)2v9hEK}4ske#0NlUKdWT$#95&rI;m zuX;v;(^~(m)%mRZ*lnm~XurIi7Qz1BYEpjYPo z#bn#WXH~-Oh+(sUomjGgkbYz4}f*b7wR}v*S|yATp({7 zXAk6x|7((k) zdbj>8F-=40$==vrayI&D=1goJ+{2tnz2t1h`SU&BrqW>%* zeb65%+Xpc4kzd~*-Y~9sYu?@_^3(VBGv{o zWkRgao|bR?U3K9yT6|`OrwPcmlk_?AU+<8`81yPG!bN?+vw8~hqrhC z^p&&Q=jGXpO~;1mxefCP{L5_grYYJW1|B(Qrx0a9o<$x@E&AijudWc#Ss7ba?GrR?XA!<9dcuD5@pcE)Z(@ z<_CYDg*7xd!5$CrfdMFZJ(0R$xGtfeR7=Et;?ldMq-wC+fqp-ovs)*;xrXs22z5)-ny-kE-oL6*7O?^k7D z!3bU(@~Lnv`y8cZ!Hrig91P#;mBfF0nUslR?98aZYH2_OL^fCP{L59KRh)u%1Ud?}*f$zln+nJ_b_?_zx6W799JD z5;ziMWpMQ-h~PFc4B`|Kyc@~_c&7_u2?`_d62l9rS`hdc1V3?tqKK)6w7}OQxb0)% zCA<-QEwZo)m1jf66elwhxc6hFFjh(sz%`xBLmnDd>^azN$-%b+Is^f}kHYdn5oMl9 zfrCX!1UG%)n~~#cIpl!C~fqFl=WmlD90BLm*`AVQ4-_!5+5 ziGx%jQ$bn+!wH-SJ_31`uczfX3WR%E*qd^amdvMM6ES2$KCbkgR73Y{ijoX|E5XMh zq{`GY070B0NM4b6&R6qdLEeEJM6S^U&Z5LKA`}`Jhp|N92p%Z~rdDL{MoE@n5g%&d zBo;WRlmWK_ZVx>$UaXV|y9B^9AT0OSgS^7RzBHDFTm;VkU}GHYI|m<`js6t9%7X7I zh$GpF literal 0 HcmV?d00001 diff --git a/gsm-backend/middleware/auth.js b/gsm-backend/middleware/auth.js new file mode 100644 index 0000000..49d1c76 --- /dev/null +++ b/gsm-backend/middleware/auth.js @@ -0,0 +1,57 @@ +import jwt from 'jsonwebtoken'; + +const ROLE_HIERARCHY = { + 'user': 1, + 'moderator': 2, + 'superadmin': 3 +}; + +export function authenticateToken(req, res, next) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: 'Token required' }); + } + + jwt.verify(token, process.env.JWT_SECRET, (err, user) => { + if (err) { + return res.status(403).json({ error: 'Invalid token' }); + } + req.user = user; + next(); + }); +} + +// Optional authentication - doesn't fail if no token +export function optionalAuth(req, res, next) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + req.user = null; + return next(); + } + + jwt.verify(token, process.env.JWT_SECRET, (err, user) => { + if (err) { + req.user = null; + } else { + req.user = user; + } + next(); + }); +} + +export function requireRole(minRole) { + return (req, res, next) => { + const userRole = req.user?.role || 'user'; + const userLevel = ROLE_HIERARCHY[userRole] || 0; + const requiredLevel = ROLE_HIERARCHY[minRole] || 0; + + if (userLevel < requiredLevel) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + next(); + }; +} diff --git a/gsm-backend/package-lock.json b/gsm-backend/package-lock.json new file mode 100644 index 0000000..03b5e39 --- /dev/null +++ b/gsm-backend/package-lock.json @@ -0,0 +1,2461 @@ +{ + "name": "gameserver-monitor-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gameserver-monitor-backend", + "version": "1.0.0", + "dependencies": { + "bcrypt": "^5.1.1", + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "discord.js": "^14.25.1", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "jsonwebtoken": "^9.0.2", + "node-fetch": "^3.3.2", + "node-ssh": "^13.2.0", + "rcon-client": "^4.2.4" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.37", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.37.tgz", + "integrity": "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", + "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nan": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", + "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", + "license": "MIT", + "optional": true + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-ssh": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/node-ssh/-/node-ssh-13.2.1.tgz", + "integrity": "sha512-rfl4GWMygQfzlExPkQ2LWyya5n2jOBm5vhEnup+4mdw7tQhNpJWbP5ldr09Jfj93k5SfY5lxcn8od5qrQ/6mBg==", + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "make-dir": "^3.1.0", + "sb-promise-queue": "^2.1.0", + "sb-scandir": "^3.1.0", + "shell-escape": "^0.2.0", + "ssh2": "^1.14.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rcon-client": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/rcon-client/-/rcon-client-4.2.5.tgz", + "integrity": "sha512-AnX1GU/ZTlwtYup3H6h0J1hwfP3OYltXVe+8ReBzmNEepX3xGH8nDg7gYqT5Y9rpAS/LmQ48h0BKINt1YGd8bA==", + "license": "MIT", + "dependencies": { + "typed-emitter": "^0.1.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sb-promise-queue": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/sb-promise-queue/-/sb-promise-queue-2.1.1.tgz", + "integrity": "sha512-qXfdcJQMxMljxmPprn4Q4hl3pJmoljSCzUvvEBa9Kscewnv56n0KqrO6yWSrGLOL9E021wcGdPa39CHGKA6G0w==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/sb-scandir": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/sb-scandir/-/sb-scandir-3.1.1.tgz", + "integrity": "sha512-Q5xiQMtoragW9z8YsVYTAZcew+cRzdVBefPbb9theaIKw6cBo34WonP9qOCTKgyAmn/Ch5gmtAxT/krUgMILpA==", + "license": "MIT", + "dependencies": { + "sb-promise-queue": "^2.1.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shell-escape": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", + "integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw==", + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-emitter": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-0.1.0.tgz", + "integrity": "sha512-Tfay0l6gJMP5rkil8CzGbLthukn+9BN/VXWcABVFPjOoelJ+koW8BuPZYk+h/L+lEeIp1fSzVRiWRPIjKVjPdg==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/gsm-backend/package.json b/gsm-backend/package.json new file mode 100644 index 0000000..df99b9a --- /dev/null +++ b/gsm-backend/package.json @@ -0,0 +1,22 @@ +{ + "name": "gameserver-monitor-backend", + "version": "1.0.0", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "bcrypt": "^5.1.1", + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "discord.js": "^14.25.1", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "jsonwebtoken": "^9.0.2", + "node-fetch": "^3.3.2", + "node-ssh": "^13.2.0", + "rcon-client": "^4.2.4" + } +} diff --git a/gsm-backend/routes/auth.js b/gsm-backend/routes/auth.js new file mode 100644 index 0000000..49d8612 --- /dev/null +++ b/gsm-backend/routes/auth.js @@ -0,0 +1,244 @@ +import { Router } from 'express'; +import jwt from 'jsonwebtoken'; +import { db, VALID_ROLES, initDiscordUsers } from '../db/init.js'; +import { authenticateToken, requireRole } from '../middleware/auth.js'; +import { getDiscordAuthUrl, exchangeCode, getDiscordUser, getGuildMember, getGuildMemberships, getUserRole, getUserRoleFromMemberships } from '../services/discord.js'; + +const router = Router(); + +// Initialize Discord users table +initDiscordUsers(); + +// ===== Guest Login ===== + +// Create guest token (view-only, expires in 24h) +router.post('/guest', (req, res) => { + const guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9); + + const token = jwt.sign( + { + id: guestId, + username: 'Gast', + role: 'guest', + isGuest: true + }, + process.env.JWT_SECRET, + { expiresIn: '24h' } + ); + + res.json({ token }); +}); + +// ===== Discord OAuth2 ===== + +// Start Discord OAuth2 flow +router.get('/discord', (req, res) => { + res.redirect(getDiscordAuthUrl()); +}); + +// Discord OAuth2 callback +router.get('/discord/callback', async (req, res) => { + const { code, error } = req.query; + + // Redirect URL for frontend + const frontendUrl = process.env.FRONTEND_URL || 'https://gsm.dimension47.de'; + + if (error) { + return res.redirect(`${frontendUrl}/login?error=discord_denied`); + } + + if (!code) { + return res.redirect(`${frontendUrl}/login?error=no_code`); + } + + try { + // Exchange code for access token + const tokenData = await exchangeCode(code); + + // Get Discord user info + const discordUser = await getDiscordUser(tokenData.access_token); + + // Check if user is in any of the configured guilds + const memberships = await getGuildMemberships(discordUser.id); + + if (!memberships) { + return res.redirect(`${frontendUrl}/login?error=not_in_guild`); + } + + // Determine role based on Discord roles (highest role from all servers) + const role = getUserRoleFromMemberships(memberships); + + // Use first membership for display name + const member = memberships[0].member; + + // Get display name (nickname or username) + const displayName = member.nick || discordUser.global_name || discordUser.username; + + // Upsert user in database + const existingUser = db.prepare('SELECT * FROM discord_users WHERE discord_id = ?').get(discordUser.id); + + let userId; + if (existingUser) { + // Update existing user + db.prepare(` + UPDATE discord_users + SET username = ?, discriminator = ?, avatar = ?, role = ?, updated_at = CURRENT_TIMESTAMP + WHERE discord_id = ? + `).run(displayName, discordUser.discriminator || '0', discordUser.avatar, role, discordUser.id); + userId = existingUser.id; + } else { + // Create new user + const result = db.prepare(` + INSERT INTO discord_users (discord_id, username, discriminator, avatar, role) + VALUES (?, ?, ?, ?, ?) + `).run(discordUser.id, displayName, discordUser.discriminator || '0', discordUser.avatar, role); + userId = result.lastInsertRowid; + } + + // Create JWT token + const token = jwt.sign( + { + id: userId, + discordId: discordUser.id, + username: displayName, + role, + avatar: discordUser.avatar + }, + process.env.JWT_SECRET, + { expiresIn: '7d' } + ); + + // Redirect to frontend with token + res.redirect(`${frontendUrl}/auth/callback?token=${token}`); + + } catch (err) { + console.error('Discord OAuth error:', err); + res.redirect(`${frontendUrl}/login?error=oauth_failed`); + } +}); + +// Get current user info +router.get('/me', authenticateToken, (req, res) => { + // Check if it's a guest user + if (req.user.isGuest) { + return res.json({ + id: req.user.id, + username: req.user.username, + role: req.user.role, + isGuest: true + }); + } + + // Check if it's a Discord user + if (req.user.discordId) { + const user = db.prepare('SELECT id, discord_id, username, avatar, role FROM discord_users WHERE id = ?').get(req.user.id); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + return res.json({ + id: user.id, + discordId: user.discord_id, + username: user.username, + avatar: user.avatar, + role: user.role + }); + } + + // Fallback for old users (shouldn't happen after migration) + const user = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.user.id); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + res.json({ id: user.id, username: user.username, role: user.role }); +}); + +// Refresh user role from Discord (useful if roles changed) +router.post('/refresh-role', authenticateToken, async (req, res) => { + if (!req.user.discordId) { + return res.status(400).json({ error: 'Not a Discord user' }); + } + + try { + const memberships = await getGuildMemberships(req.user.discordId); + + if (!memberships) { + return res.status(403).json({ error: 'No longer in any guild' }); + } + + const newRole = getUserRoleFromMemberships(memberships); + + db.prepare('UPDATE discord_users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE discord_id = ?') + .run(newRole, req.user.discordId); + + // Generate new token with updated role + const token = jwt.sign( + { + id: req.user.id, + discordId: req.user.discordId, + username: req.user.username, + role: newRole, + avatar: req.user.avatar + }, + process.env.JWT_SECRET, + { expiresIn: '7d' } + ); + + res.json({ token, role: newRole }); + } catch (err) { + console.error('Failed to refresh role:', err); + res.status(500).json({ error: 'Failed to refresh role' }); + } +}); + +// ===== User Management (superadmin only) ===== + +// Get all Discord users +router.get('/users', authenticateToken, requireRole('superadmin'), (req, res) => { + const users = db.prepare(` + SELECT id, discord_id, username, avatar, role, created_at, updated_at + FROM discord_users + ORDER BY created_at DESC + `).all(); + res.json(users); +}); + +// Update user role (override Discord role) +router.patch('/users/:id/role', authenticateToken, requireRole('superadmin'), (req, res) => { + const userId = parseInt(req.params.id); + const { role } = req.body; + + if (!VALID_ROLES.includes(role)) { + return res.status(400).json({ error: 'Invalid role' }); + } + + if (userId === req.user.id) { + return res.status(400).json({ error: 'Cannot change your own role' }); + } + + const user = db.prepare('SELECT id FROM discord_users WHERE id = ?').get(userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + db.prepare('UPDATE discord_users SET role = ? WHERE id = ?').run(role, userId); + res.json({ message: 'Role updated' }); +}); + +// Delete user +router.delete('/users/:id', authenticateToken, requireRole('superadmin'), (req, res) => { + const userId = parseInt(req.params.id); + + if (userId === req.user.id) { + return res.status(400).json({ error: 'Cannot delete yourself' }); + } + + const user = db.prepare('SELECT id FROM discord_users WHERE id = ?').get(userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + db.prepare('DELETE FROM discord_users WHERE id = ?').run(userId); + res.json({ message: 'User deleted' }); +}); + +export default router; diff --git a/gsm-backend/routes/servers.js b/gsm-backend/routes/servers.js index f6800b5..259eaba 100644 --- a/gsm-backend/routes/servers.js +++ b/gsm-backend/routes/servers.js @@ -3,10 +3,10 @@ import { readFileSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { authenticateToken, optionalAuth, requireRole } from '../middleware/auth.js'; -import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig } from '../services/ssh.js'; +import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig, readTerrariaConfig, writeTerrariaConfig, readOpenTTDConfig, writeOpenTTDConfig } from '../services/ssh.js'; import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js'; import { getServerMetricsHistory, getCurrentMetrics } from '../services/prometheus.js'; -import { initWhitelistCache, getCachedWhitelist, setCachedWhitelist, initFactorioTemplates, getFactorioTemplates, createFactorioTemplate, deleteFactorioTemplate, initFactorioWorldSettings, getFactorioWorldSettings, saveFactorioWorldSettings, deleteFactorioWorldSettings, initAutoShutdownSettings, getAutoShutdownSettings, setAutoShutdownSettings, initActivityLog, logActivity, getActivityLog, initServerDisplaySettings, getServerDisplaySettings, getAllServerDisplaySettings, setServerDisplaySettings } from '../db/init.js'; +import { initWhitelistCache, getCachedWhitelist, setCachedWhitelist, initFactorioTemplates, getFactorioTemplates, createFactorioTemplate, deleteFactorioTemplate, initFactorioWorldSettings, getFactorioWorldSettings, saveFactorioWorldSettings, deleteFactorioWorldSettings, initAutoShutdownSettings, getAutoShutdownSettings, setAutoShutdownSettings, initActivityLog, logActivity, getActivityLog, initServerDisplaySettings, getServerDisplaySettings, getAllServerDisplaySettings, setServerDisplaySettings, initGuildSettings } from '../db/init.js'; import { getEmptySince } from '../services/autoshutdown.js'; import { getDefaultMapGenSettings, getPresetNames, getPreset } from '../services/factorio.js'; @@ -15,6 +15,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); function loadConfig() { return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8')); } +// RAM Budget Checkasync function checkRamBudget(serverToStart) { const config = loadConfig(); const ramBudget = config.ramBudget || 30; let usedRam = 0; for (const server of config.servers) { if (server.id === serverToStart.id) continue; try { const status = await getServerStatus(server); if (status === "online") usedRam += server.maxRam || 0; } catch (err) {} } const serverRam = serverToStart.maxRam || 0; const availableRam = ramBudget - usedRam; return { canStart: availableRam >= serverRam, usedRam, serverRam, availableRam, ramBudget };} // Initialize tables initWhitelistCache(); @@ -22,6 +23,7 @@ initActivityLog(); initFactorioTemplates(); initFactorioWorldSettings(); initServerDisplaySettings(); +initGuildSettings(); const router = Router(); @@ -409,6 +411,73 @@ router.get("/display-settings", optionalAuth, async (req, res) => { } }); +// ============ TERRARIA ROUTES ============ + +// Get Terraria config +router.get("/terraria/config", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.id === "terraria"); + if (!server) return res.status(404).json({ error: "Server not found" }); + const content = await readTerrariaConfig(server); + res.json({ content }); + } catch (error) { + console.error("Error reading Terraria config:", error); + res.status(500).json({ error: error.message }); + } +}); + +// Save Terraria config +router.put("/terraria/config", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.id === "terraria"); + if (!server) return res.status(404).json({ error: "Server not found" }); + const { content } = req.body; + if (!content) return res.status(400).json({ error: "Content required" }); + await writeTerrariaConfig(server, content); + logActivity(req.user.id, req.user.username, "terraria_config", "terraria", "serverconfig.txt", req.user.discordId, req.user.avatar); + res.json({ success: true }); + } catch (error) { + console.error("Error writing Terraria config:", error); + res.status(500).json({ error: error.message }); + } +}); + +// ============ OPENTTD ROUTES ============ + +// Get OpenTTD config +router.get("/openttd/config", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.id === "openttd"); + if (!server) return res.status(404).json({ error: "Server not found" }); + const content = await readOpenTTDConfig(server); + res.json({ content }); + } catch (error) { + console.error("Error reading OpenTTD config:", error); + res.status(500).json({ error: error.message }); + } +}); + +// Save OpenTTD config +router.put("/openttd/config", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.id === "openttd"); + if (!server) return res.status(404).json({ error: "Server not found" }); + const { content } = req.body; + if (!content) return res.status(400).json({ error: "Content required" }); + await writeOpenTTDConfig(server, content); + logActivity(req.user.id, req.user.username, "openttd_config", "openttd", "openttd.cfg", req.user.discordId, req.user.avatar); + res.json({ success: true }); + } catch (error) { + console.error("Error writing OpenTTD config:", error); + res.status(500).json({ error: error.message }); + } +}); + + // Get single server router.get('/:id', optionalAuth, async (req, res) => { const config = loadConfig(); diff --git a/gsm-backend/routes/servers.js.bak b/gsm-backend/routes/servers.js.bak new file mode 100644 index 0000000..2757e4d --- /dev/null +++ b/gsm-backend/routes/servers.js.bak @@ -0,0 +1,679 @@ +import { Router } from 'express'; +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { authenticateToken, optionalAuth, requireRole } from '../middleware/auth.js'; +import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig } from '../services/ssh.js'; +import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js'; +import { getServerMetricsHistory, getCurrentMetrics } from '../services/prometheus.js'; +import { initWhitelistCache, getCachedWhitelist, setCachedWhitelist, initFactorioTemplates, getFactorioTemplates, createFactorioTemplate, deleteFactorioTemplate, initFactorioWorldSettings, getFactorioWorldSettings, saveFactorioWorldSettings, deleteFactorioWorldSettings, initAutoShutdownSettings, getAutoShutdownSettings, setAutoShutdownSettings, initActivityLog, logActivity, getActivityLog, initServerDisplaySettings, getServerDisplaySettings, getAllServerDisplaySettings, setServerDisplaySettings, initGuildSettings } from '../db/init.js'; +import { getEmptySince } from '../services/autoshutdown.js'; +import { getDefaultMapGenSettings, getPresetNames, getPreset } from '../services/factorio.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function loadConfig() { + return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8')); +} +// RAM Budget Checkasync function checkRamBudget(serverToStart) { const config = loadConfig(); const ramBudget = config.ramBudget || 30; let usedRam = 0; for (const server of config.servers) { if (server.id === serverToStart.id) continue; try { const status = await getServerStatus(server); if (status === "online") usedRam += server.maxRam || 0; } catch (err) {} } const serverRam = serverToStart.maxRam || 0; const availableRam = ramBudget - usedRam; return { canStart: availableRam >= serverRam, usedRam, serverRam, availableRam, ramBudget };} + +// Initialize tables +initWhitelistCache(); +initActivityLog(); +initFactorioTemplates(); +initFactorioWorldSettings(); +initServerDisplaySettings(); +initGuildSettings(); + +const router = Router(); + +function formatBytes(bytes, forceUnit = null) { + if (bytes === 0) return { value: 0, unit: forceUnit || "B" }; + const gb = bytes / (1024 * 1024 * 1024); + const mb = bytes / (1024 * 1024); + if (forceUnit === "GB") return { value: gb, unit: "GB" }; + if (forceUnit === "MB") return { value: mb, unit: "MB" }; + if (gb >= 1) return { value: gb, unit: "GB" }; + return { value: mb, unit: "MB" }; +} + +// ============ FACTORIO ROUTES ============ + +// Factorio: List saves +router.get("/factorio/saves", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.type === "factorio"); + if (!server) return res.status(404).json({ error: "Factorio server not configured" }); + + const saves = await listFactorioSaves(server); + res.json({ saves }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Factorio: Get presets and default settings +router.get("/factorio/presets", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const presets = getPresetNames(); + const defaultSettings = getDefaultMapGenSettings(); + res.json({ presets, defaultSettings }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Factorio: Get preset by name +router.get("/factorio/presets/:name", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const preset = getPreset(req.params.name); + res.json({ settings: preset }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Factorio: List templates +router.get("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const templates = getFactorioTemplates(); + res.json({ templates: templates.map(t => ({ ...t, settings: JSON.parse(t.settings) })) }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Factorio: Create template +router.post("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const { name, settings } = req.body; + if (!name || !settings) { + return res.status(400).json({ error: "Name and settings required" }); + } + const id = createFactorioTemplate(name, settings, req.user.id); + res.json({ id, message: "Template created" }); + } catch (err) { + if (err.message.includes("UNIQUE constraint")) { + return res.status(400).json({ error: "Template name already exists" }); + } + res.status(500).json({ error: err.message }); + } +}); + +// Factorio: Delete template +router.delete("/factorio/templates/:id", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const result = deleteFactorioTemplate(parseInt(req.params.id)); + if (result.changes === 0) { + return res.status(404).json({ error: "Template not found" }); + } + res.json({ message: "Template deleted" }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Factorio: Create new world +router.post("/factorio/create-world", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const { saveName, settings } = req.body; + if (!saveName) { + return res.status(400).json({ error: "Save name required" }); + } + + const config = loadConfig(); + const server = config.servers.find(s => s.type === "factorio"); + if (!server) return res.status(404).json({ error: "Factorio server not configured" }); + + const finalSettings = settings || getDefaultMapGenSettings(); + await createFactorioWorld(server, saveName, finalSettings); + + // Save settings to database for later reference + saveFactorioWorldSettings(saveName, finalSettings, req.user.id); + logActivity(req.user.id, req.user.username, 'factorio_world_create', 'factorio', saveName, req.user.discordId, req.user.avatar); + + res.json({ message: "World created", saveName }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Factorio: Delete save +router.delete("/factorio/saves/:name", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.type === "factorio"); + if (!server) return res.status(404).json({ error: "Factorio server not configured" }); + + await deleteFactorioSave(server, req.params.name); + // Also delete stored settings if they exist + deleteFactorioWorldSettings(req.params.name); + logActivity(req.user.id, req.user.username, 'factorio_world_delete', 'factorio', req.params.name, req.user.discordId, req.user.avatar); + res.json({ message: "Save deleted" }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Factorio: Get world settings +router.get("/factorio/saves/:name/settings", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const settings = getFactorioWorldSettings(req.params.name); + if (!settings) { + return res.json({ + legacy: true, + message: "This is a legacy world created before settings tracking was implemented" + }); + } + res.json({ + legacy: false, + settings: JSON.parse(settings.settings), + createdBy: settings.created_by, + createdAt: settings.created_at + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Factorio: Get current/default save +router.get("/factorio/current-save", authenticateToken, async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.type === "factorio"); + if (!server) return res.status(404).json({ error: "Factorio server not configured" }); + + const result = await getFactorioCurrentSave(server); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ ZOMBOID CONFIG ROUTES ============ + +// Zomboid: List config files +router.get("/zomboid/config", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.type === "zomboid"); + if (!server) return res.status(404).json({ error: "Zomboid server not configured" }); + + const files = await listZomboidConfigs(server); + res.json({ files }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Zomboid: Read config file +router.get("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.type === "zomboid"); + if (!server) return res.status(404).json({ error: "Zomboid server not configured" }); + + const content = await readZomboidConfig(server, req.params.filename); + res.json({ filename: req.params.filename, content }); + } catch (err) { + if (err.message === "File not allowed") { + return res.status(403).json({ error: err.message }); + } + res.status(500).json({ error: err.message }); + } +}); + +// Zomboid: Write config file +router.put("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.type === "zomboid"); + if (!server) return res.status(404).json({ error: "Zomboid server not configured" }); + + const { content } = req.body; + if (content === undefined) { + return res.status(400).json({ error: "Content required" }); + } + + await writeZomboidConfig(server, req.params.filename, content); + logActivity(req.user.id, req.user.username, 'zomboid_config', 'zomboid', req.params.filename, req.user.discordId, req.user.avatar); + res.json({ message: "Config saved", filename: req.params.filename }); + } catch (err) { + if (err.message === "File not allowed") { + return res.status(403).json({ error: err.message }); + } + res.status(500).json({ error: err.message }); + } +}); + + +// Palworld: List config files +router.get("/palworld/config", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.type === "palworld"); + if (!server) return res.status(404).json({ error: "Palworld server not configured" }); + const files = await listPalworldConfigs(server); + res.json({ files }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Palworld: Read config file +router.get("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.type === "palworld"); + if (!server) return res.status(404).json({ error: "Palworld server not configured" }); + const content = await readPalworldConfig(server, req.params.filename); + res.json({ filename: req.params.filename, content }); + } catch (err) { + if (err.message === "File not allowed") { + return res.status(403).json({ error: err.message }); + } + res.status(500).json({ error: err.message }); + } +}); + +// Palworld: Write config file +router.put("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.type === "palworld"); + if (!server) return res.status(404).json({ error: "Palworld server not configured" }); + const { content } = req.body; + if (content === undefined) { + return res.status(400).json({ error: "Content required" }); + } + await writePalworldConfig(server, req.params.filename, content); + logActivity(req.user.id, req.user.username, "palworld_config", "palworld", req.params.filename, req.user.discordId, req.user.avatar); + res.json({ message: "Config saved", filename: req.params.filename }); + } catch (err) { + if (err.message === "File not allowed") { + return res.status(403).json({ error: err.message }); + } + res.status(500).json({ error: err.message }); + } +}); + +// ============ GENERAL ROUTES ============ + +// Get all servers with status +router.get('/', optionalAuth, async (req, res) => { + try { + const config = loadConfig(); + const servers = await Promise.all(config.servers.map(async (server) => { + // Quick check if host is unreachable - skip expensive operations + const hostUnreachable = isHostFailed(server.host, server.sshUser); + // If host is unreachable, return immediately with minimal data + if (hostUnreachable) { + const metrics = await getCurrentMetrics(server.id).catch(() => ({ + cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0 + })); + const memTotal = formatBytes(metrics.memoryTotal); + const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit); + return { + id: server.id, + name: server.name, + type: server.type, + status: "unreachable", + running: false, + metrics: { + cpu: metrics.cpu, + cpuCores: metrics.cpuCores, + memory: metrics.memory, + memoryUsed: memUsed.value, + memoryTotal: memTotal.value, + memoryUnit: memTotal.unit, + uptime: 0 + }, + players: { online: 0, max: null, list: [] }, + hasRcon: !!server.rconPassword + }; + } + + const [status, metrics, players, playerList, processUptime] = await Promise.all([ + getServerStatus(server), + getCurrentMetrics(server.id).catch(() => ({ + cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0 + })), + server.rconPassword ? getPlayers(server).catch(() => ({ online: 0, max: null })) : { online: 0, max: null }, + server.rconPassword ? getPlayerList(server).catch(() => ({ players: [] })) : { players: [] }, + getProcessUptime(server).catch(() => 0) + ]); + + const memTotal = formatBytes(metrics.memoryTotal); + const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit); + + // Get auto-shutdown info + const shutdownSettings = getAutoShutdownSettings(server.id); + const emptySince = getEmptySince(server.id); + + return { + id: server.id, + name: server.name, + type: server.type, + status, + running: status === 'online', + metrics: { + cpu: metrics.cpu, + cpuCores: metrics.cpuCores, + memory: metrics.memory, + memoryUsed: memUsed.value, + memoryTotal: memTotal.value, + memoryUnit: memTotal.unit, + uptime: processUptime + }, + players: { + ...players, + list: playerList.players + }, + hasRcon: !!server.rconPassword, + autoShutdown: { + enabled: shutdownSettings?.enabled === 1 || false, + timeoutMinutes: shutdownSettings?.timeout_minutes || 15, + emptySinceMinutes: emptySince + } + }; + })); + + res.json(servers); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + + +// Activity Log (superadmin only) +router.get('/activity-log', authenticateToken, requireRole('superadmin'), (req, res) => { + try { + const limit = parseInt(req.query.limit) || 100; + const logs = getActivityLog(limit); + res.json(logs); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get all server display settings (for ServerCard) +router.get("/display-settings", optionalAuth, async (req, res) => { + try { + const settings = getAllServerDisplaySettings(); + const result = {}; + settings.forEach(s => { + result[s.server_id] = { address: s.address, hint: s.hint }; + }); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get single server +router.get('/:id', optionalAuth, async (req, res) => { + const config = loadConfig(); + const server = config.servers.find(s => s.id === req.params.id); + if (!server) { + return res.status(404).json({ error: 'Server not found' }); + } + + try { + const [status, metrics, players, playerList, processUptime] = await Promise.all([ + getServerStatus(server), + getCurrentMetrics(server.id), + server.rconPassword ? getPlayers(server) : { online: 0, max: null }, + server.rconPassword ? getPlayerList(server) : { players: [] }, + getProcessUptime(server).catch(() => 0) + ]); + + const memTotal = formatBytes(metrics.memoryTotal); + const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit); + + res.json({ + id: server.id, + name: server.name, + type: server.type, + status, + running: status === 'online', + metrics: { + cpu: metrics.cpu, + cpuCores: metrics.cpuCores, + memory: metrics.memory, + memoryUsed: memUsed.value, + memoryTotal: memTotal.value, + memoryUnit: memTotal.unit, + uptime: processUptime + }, + players: { + ...players, + list: playerList.players + }, + hasRcon: !!server.rconPassword + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get metrics history from Prometheus +router.get('/:id/metrics/history', optionalAuth, async (req, res) => { + const config = loadConfig(); + const server = config.servers.find(s => s.id === req.params.id); + if (!server) return res.status(404).json({ error: 'Server not found' }); + + const range = req.query.range || '1h'; + const validRanges = ['15m', '1h', '6h', '24h']; + if (!validRanges.includes(range)) { + return res.status(400).json({ error: 'Invalid range. Valid: 15m, 1h, 6h, 24h' }); + } + + try { + const history = await getServerMetricsHistory(server.id, range); + res.json(history); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get player list +router.get('/:id/players', authenticateToken, async (req, res) => { + const config = loadConfig(); + const server = config.servers.find(s => s.id === req.params.id); + if (!server) return res.status(404).json({ error: 'Server not found' }); + + if (!server.rconPassword) { + return res.status(400).json({ error: 'RCON not configured for this server' }); + } + + try { + const [count, list] = await Promise.all([ + getPlayers(server), + getPlayerList(server) + ]); + res.json({ ...count, list: list.players }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get console logs (moderator+) +router.get('/:id/logs', authenticateToken, requireRole('moderator'), async (req, res) => { + const config = loadConfig(); + const server = config.servers.find(s => s.id === req.params.id); + if (!server) return res.status(404).json({ error: 'Server not found' }); + + try { + const lines = parseInt(req.query.lines) || 100; + const logs = await getConsoleLog(server, lines); + res.json({ logs }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Power actions (moderator+) +router.post('/:id/start', authenticateToken, requireRole('moderator'), async (req, res) => { + const config = loadConfig(); + const server = config.servers.find(s => s.id === req.params.id); + if (!server) return res.status(404).json({ error: 'Server not found' }); + + try { + const { save } = req.body || {}; + await startServer(server, { save }); + logActivity(req.user.id, req.user.username, 'server_start', server.id, save ? 'Save: ' + save : null, req.user.discordId, req.user.avatar); + res.json({ message: 'Server starting' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/:id/stop', authenticateToken, requireRole('moderator'), async (req, res) => { + const config = loadConfig(); + const server = config.servers.find(s => s.id === req.params.id); + if (!server) return res.status(404).json({ error: 'Server not found' }); + + try { + await stopServer(server); + logActivity(req.user.id, req.user.username, 'server_stop', server.id, null, req.user.discordId, req.user.avatar); + res.json({ message: 'Server stopping' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/:id/restart', authenticateToken, requireRole('moderator'), async (req, res) => { + const config = loadConfig(); + const server = config.servers.find(s => s.id === req.params.id); + if (!server) return res.status(404).json({ error: 'Server not found' }); + + try { + await restartServer(server); + logActivity(req.user.id, req.user.username, 'server_restart', server.id, null, req.user.discordId, req.user.avatar); + res.json({ message: 'Server restarting' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get whitelist (with server-side caching) +router.get('/:id/whitelist', optionalAuth, async (req, res) => { + const config = loadConfig(); + const server = config.servers.find(s => s.id === req.params.id); + if (!server) return res.status(404).json({ error: 'Server not found' }); + + if (!server.rconPassword) { + return res.json({ players: [], cached: false }); + } + + try { + const response = await sendRconCommand(server, 'whitelist list'); + const match = response.trim().match(/:\s*(.+)$/); + let players = []; + if (match && match[1]) { + players = match[1].split(',').map(p => p.trim()).filter(p => p.length > 0); + } + setCachedWhitelist(server.id, players); + res.json({ players, cached: false }); + } catch (err) { + const players = getCachedWhitelist(server.id); + res.json({ players, cached: true }); + } +}); + +// RCON command (moderator+) +router.post('/:id/rcon', authenticateToken, requireRole('moderator'), async (req, res) => { + const config = loadConfig(); + const server = config.servers.find(s => s.id === req.params.id); + if (!server) return res.status(404).json({ error: 'Server not found' }); + + if (!server.rconPassword) { + return res.status(400).json({ error: 'RCON not configured for this server' }); + } + + const { command } = req.body; + if (!command) { + return res.status(400).json({ error: 'Command required' }); + } + + try { + const response = await sendRconCommand(server, command); + logActivity(req.user.id, req.user.username, 'rcon_command', server.id, command, req.user.discordId, req.user.avatar); + if (command.startsWith("whitelist ")) { + try { + const listResponse = await sendRconCommand(server, "whitelist list"); + const match = listResponse.trim().match(/:\s*(.+)$/); + let players = []; + if (match && match[1]) { + players = match[1].split(",").map(p => p.trim()).filter(p => p.length > 0); + } + setCachedWhitelist(server.id, players); + } catch (e) { /* ignore */ } + } + res.json({ response }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + + +// Initialize auto-shutdown settings table +initAutoShutdownSettings(); + +// ============ AUTO-SHUTDOWN ROUTES ============ + +// Get auto-shutdown settings for a server +router.get('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => { + const config = loadConfig(); + const server = config.servers.find(s => s.id === req.params.id); + if (!server) return res.status(404).json({ error: 'Server not found' }); + + const settings = getAutoShutdownSettings(req.params.id); + const emptySince = getEmptySince(req.params.id); + + res.json({ + enabled: settings?.enabled === 1 || false, + timeoutMinutes: settings?.timeout_minutes || 15, + emptySinceMinutes: emptySince + }); +}); + +// Update auto-shutdown settings for a server +router.put('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => { + const config = loadConfig(); + const server = config.servers.find(s => s.id === req.params.id); + if (!server) return res.status(404).json({ error: 'Server not found' }); + + const { enabled, timeoutMinutes } = req.body; + const timeout = Math.max(1, Math.min(1440, timeoutMinutes || 15)); + + setAutoShutdownSettings(req.params.id, enabled, timeout); + logActivity(req.user.id, req.user.username, 'autoshutdown_config', req.params.id, 'Enabled: ' + enabled + ', Timeout: ' + timeout + ' min', req.user.discordId, req.user.avatar); + console.log('[AutoShutdown] Settings updated for ' + req.params.id + ': enabled=' + enabled + ', timeout=' + timeout + 'min'); + + res.json({ message: 'Auto-shutdown settings updated', enabled, timeoutMinutes: timeout }); +}); + + +// Get display settings for a specific server +router.get("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => { + try { + const settings = getServerDisplaySettings(req.params.id); + res.json(settings || { server_id: req.params.id, address: "", hint: "" }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Update display settings for a server (superadmin only) +router.put("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => { + const { address, hint } = req.body; + try { + setServerDisplaySettings(req.params.id, address || "", hint || ""); + res.json({ message: "Display settings updated", address, hint }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); +export default router; diff --git a/gsm-backend/server.js b/gsm-backend/server.js new file mode 100644 index 0000000..f175d22 --- /dev/null +++ b/gsm-backend/server.js @@ -0,0 +1,37 @@ +import express from 'express'; +import cors from 'cors'; +import { config } from 'dotenv'; +import authRoutes from './routes/auth.js'; +import serverRoutes from './routes/servers.js'; +import { initDb } from './db/init.js'; +import { startAutoShutdownService } from './services/autoshutdown.js'; +import { initDiscordBot } from './services/discordBot.js'; + +config(); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(cors()); +app.use(express.json()); + +// Initialize database +initDb(); + +// Routes +app.use('/api/auth', authRoutes); +app.use('/api/servers', serverRoutes); + +app.get('/api/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`Server running on port ${PORT}`); + + // Start auto-shutdown service + startAutoShutdownService(); + + // Start Discord bot + initDiscordBot(); +}); diff --git a/gsm-backend/services/autoshutdown.js b/gsm-backend/services/autoshutdown.js new file mode 100644 index 0000000..61bf7ab --- /dev/null +++ b/gsm-backend/services/autoshutdown.js @@ -0,0 +1,113 @@ +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { getServerStatus, stopServer } from './ssh.js'; +import { getPlayers } from './rcon.js'; +import { getAllAutoShutdownSettings, getAutoShutdownSettings } from '../db/init.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Track when each server became empty +const emptyPlayersSince = new Map(); + +// Check interval in ms (60 seconds) +const CHECK_INTERVAL = 60000; + +function loadConfig() { + return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8')); +} + +async function checkServers() { + try { + const config = loadConfig(); + const enabledSettings = getAllAutoShutdownSettings(); + + // Create a map for quick lookup + const settingsMap = new Map(enabledSettings.map(s => [s.server_id, s])); + + for (const server of config.servers) { + const settings = settingsMap.get(server.id); + + // Skip if auto-shutdown not enabled for this server + if (!settings || !settings.enabled) { + emptyPlayersSince.delete(server.id); + continue; + } + + try { + // Check if server is online + const status = await getServerStatus(server); + + if (status !== 'online') { + // Server not running, clear timer + emptyPlayersSince.delete(server.id); + continue; + } + + // Get player count + let playerCount = 0; + if (server.rconPassword) { + const players = await getPlayers(server); + playerCount = players.online || 0; + } + + if (playerCount === 0) { + // No players online + if (!emptyPlayersSince.has(server.id)) { + // Start tracking empty time + emptyPlayersSince.set(server.id, Date.now()); + console.log(`[AutoShutdown] ${server.id}: Keine Spieler online, Timer gestartet`); + } + + const emptyMs = Date.now() - emptyPlayersSince.get(server.id); + const emptyMinutes = emptyMs / 60000; + + if (emptyMinutes >= settings.timeout_minutes) { + console.log(`[AutoShutdown] ${server.id}: Timeout erreicht (${settings.timeout_minutes} Min), stoppe Server...`); + await stopServer(server); + emptyPlayersSince.delete(server.id); + console.log(`[AutoShutdown] ${server.id}: Server gestoppt`); + } + } else { + // Players online, reset timer + if (emptyPlayersSince.has(server.id)) { + console.log(`[AutoShutdown] ${server.id}: Spieler online (${playerCount}), Timer zurückgesetzt`); + emptyPlayersSince.delete(server.id); + } + } + } catch (err) { + console.error(`[AutoShutdown] Fehler bei ${server.id}:`, err.message); + } + } + } catch (err) { + console.error('[AutoShutdown] Fehler beim Laden der Config:', err.message); + } +} + +export function startAutoShutdownService() { + console.log('[AutoShutdown] Service gestartet, prüfe alle 60 Sekunden'); + + // Initial check after 10 seconds (give server time to start) + setTimeout(() => { + checkServers(); + }, 10000); + + // Then check every 60 seconds + setInterval(checkServers, CHECK_INTERVAL); +} + +// Get how long a server has been empty (for status display) +export function getEmptySince(serverId) { + const since = emptyPlayersSince.get(serverId); + if (!since) return null; + return Math.floor((Date.now() - since) / 60000); // Return minutes +} + +// Get all empty-since times +export function getAllEmptySince() { + const result = {}; + for (const [serverId, since] of emptyPlayersSince) { + result[serverId] = Math.floor((Date.now() - since) / 60000); + } + return result; +} diff --git a/gsm-backend/services/discord.js b/gsm-backend/services/discord.js new file mode 100644 index 0000000..5e2e8a4 --- /dev/null +++ b/gsm-backend/services/discord.js @@ -0,0 +1,167 @@ +// Discord OAuth2 Service +const DISCORD_API = 'https://discord.com/api/v10'; + +// Lazy initialization - wird erst bei Verwendung geladen (nach dotenv) +let _guildConfigs = null; + +function getGuildConfigs() { + if (_guildConfigs === null) { + _guildConfigs = [ + { + name: 'Bacanaks', + guildId: process.env.DISCORD_GUILD_ID_1, + adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_1, + modRoleId: process.env.DISCORD_MOD_ROLE_ID_1 + }, + { + name: 'Piccadilly', + guildId: process.env.DISCORD_GUILD_ID_2, + adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_2, + modRoleId: process.env.DISCORD_MOD_ROLE_ID_2 + } + ].filter(config => config.guildId); + } + return _guildConfigs; +} + +export function getDiscordAuthUrl() { + const params = new URLSearchParams({ + client_id: process.env.DISCORD_CLIENT_ID, + redirect_uri: process.env.DISCORD_REDIRECT_URI, + response_type: 'code', + scope: 'identify guilds.members.read' + }); + return `https://discord.com/oauth2/authorize?${params}`; +} + +export async function exchangeCode(code) { + const response = await fetch(`${DISCORD_API}/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: process.env.DISCORD_CLIENT_ID, + client_secret: process.env.DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: process.env.DISCORD_REDIRECT_URI + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to exchange code: ${error}`); + } + + return response.json(); +} + +export async function getDiscordUser(accessToken) { + const response = await fetch(`${DISCORD_API}/users/@me`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + if (!response.ok) { + throw new Error('Failed to get Discord user'); + } + + return response.json(); +} + +// Prüft einen einzelnen Server +async function fetchGuildMember(guildId, userId) { + const response = await fetch( + `${DISCORD_API}/guilds/${guildId}/members/${userId}`, + { + headers: { + Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}` + } + } + ); + + if (!response.ok) { + if (response.status === 404) { + return null; + } + throw new Error(`Failed to get guild member from ${guildId}`); + } + + return response.json(); +} + +// Prüft alle konfigurierten Server und gibt Memberships zurück +export async function getGuildMemberships(userId) { + const configs = getGuildConfigs(); + const memberships = []; + + for (const config of configs) { + try { + const member = await fetchGuildMember(config.guildId, userId); + if (member) { + memberships.push({ + config, + member, + roles: member.roles || [] + }); + } + } catch (err) { + console.error(`[Discord] Failed to check membership for guild ${config.name}:`, err.message); + } + } + + return memberships.length > 0 ? memberships : null; +} + +// Legacy-Funktion für Kompatibilität +export async function getGuildMember(userId) { + const memberships = await getGuildMemberships(userId); + if (!memberships || memberships.length === 0) { + return null; + } + return memberships[0].member; +} + +// Rollen-Priorität: superadmin > moderator > user +const ROLE_PRIORITY = { superadmin: 3, moderator: 2, user: 1 }; + +// Bestimmt die höchste Rolle aus allen Server-Memberships +export function getUserRoleFromMemberships(memberships) { + if (!memberships || memberships.length === 0) { + return 'user'; + } + + let highestRole = 'user'; + + for (const { config, roles } of memberships) { + let role = 'user'; + + if (roles.includes(config.adminRoleId)) { + role = 'superadmin'; + } else if (roles.includes(config.modRoleId)) { + role = 'moderator'; + } + + if (ROLE_PRIORITY[role] > ROLE_PRIORITY[highestRole]) { + highestRole = role; + } + } + + return highestRole; +} + +// Legacy-Funktion für Kompatibilität +export function getUserRole(memberRoles) { + const adminRoleId = process.env.DISCORD_ADMIN_ROLE_ID || process.env.DISCORD_ADMIN_ROLE_ID_1; + const modRoleId = process.env.DISCORD_MOD_ROLE_ID || process.env.DISCORD_MOD_ROLE_ID_1; + + if (memberRoles.includes(adminRoleId)) { + return 'superadmin'; + } + if (memberRoles.includes(modRoleId)) { + return 'moderator'; + } + return 'user'; +} diff --git a/gsm-backend/services/discordBot.js b/gsm-backend/services/discordBot.js index 4a28670..4c5e875 100644 --- a/gsm-backend/services/discordBot.js +++ b/gsm-backend/services/discordBot.js @@ -20,7 +20,9 @@ const serverDisplay = { factorio: { name: 'Factorio', icon: '⚙️', color: 0xF97316, address: 'factorio.zeasy.dev' }, zomboid: { name: 'Project Zomboid', icon: '🧟', color: 0x4ADE80, address: 'pz.zeasy.dev:16261' }, vrising: { name: 'V Rising', icon: '🧛', color: 0xDC2626, address: 'vrising.zeasy.dev' }, - palworld: { name: 'Palworld', icon: '🦎', color: 0x00D4AA, address: 'palworld.zeasy.dev:8211' } + palworld: { name: 'Palworld', icon: '🦎', color: 0x00D4AA, address: 'palworld.zeasy.dev:8211' }, + terraria: { name: 'Terraria', icon: '⚔️', color: 0x05C46B, address: 'terraria.zeasy.dev:7777' }, + openttd: { name: 'OpenTTD', icon: '🚂', color: 0x1E90FF, address: 'openttd.zeasy.dev:3979' } }; function loadConfig() { @@ -497,17 +499,28 @@ export async function initDiscordBot() { console.log('[DiscordBot] Left guild: ' + guild.name + ' (' + guild.id + ')'); deleteGuildSettings(guild.id); }); - client.once('ready', async () => { console.log('[DiscordBot] Logged in as ' + client.user.tag); + // Check for guilds without settings and set them up + for (const [guildId, guild] of client.guilds.cache) { + const settings = getGuildSettings(guildId); + if (!settings) { + console.log('[DiscordBot] Setting up missing guild: ' + guild.name); + try { + await setupGuildChannels(guild); + } catch (err) { + console.error('[DiscordBot] Failed to setup missing guild ' + guild.name + ':', err); + } + } + } + // First run - populate state without alerts await updateAllStatusMessages(true); // Regular updates every 60 seconds setInterval(() => updateAllStatusMessages(false), 60000); }); - client.login(token).catch(err => { console.error('[DiscordBot] Failed to login:', err.message); }); diff --git a/gsm-backend/services/factorio.js b/gsm-backend/services/factorio.js new file mode 100644 index 0000000..6c4a024 --- /dev/null +++ b/gsm-backend/services/factorio.js @@ -0,0 +1,99 @@ +import { readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function loadConfig() { + return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8")); +} + +export function getFactorioServer() { + const config = loadConfig(); + return config.servers.find(s => s.type === "factorio"); +} + +// Default map-gen-settings structure +export function getDefaultMapGenSettings() { + return { + terrain_segmentation: 1, + water: 1, + width: 0, + height: 0, + starting_area: 1, + peaceful_mode: false, + autoplace_controls: { + coal: { frequency: 1, size: 1, richness: 1 }, + stone: { frequency: 1, size: 1, richness: 1 }, + "copper-ore": { frequency: 1, size: 1, richness: 1 }, + "iron-ore": { frequency: 1, size: 1, richness: 1 }, + "uranium-ore": { frequency: 1, size: 1, richness: 1 }, + "crude-oil": { frequency: 1, size: 1, richness: 1 }, + trees: { frequency: 1, size: 1, richness: 1 }, + "enemy-base": { frequency: 1, size: 1, richness: 1 } + }, + cliff_settings: { + name: "cliff", + cliff_elevation_0: 10, + cliff_elevation_interval: 40, + richness: 1 + }, + seed: null + }; +} + +// Factorio presets +export const FACTORIO_PRESETS = { + default: getDefaultMapGenSettings(), + "rich-resources": { + ...getDefaultMapGenSettings(), + autoplace_controls: { + coal: { frequency: 1, size: 1, richness: 2 }, + stone: { frequency: 1, size: 1, richness: 2 }, + "copper-ore": { frequency: 1, size: 1, richness: 2 }, + "iron-ore": { frequency: 1, size: 1, richness: 2 }, + "uranium-ore": { frequency: 1, size: 1, richness: 2 }, + "crude-oil": { frequency: 1, size: 1, richness: 2 }, + trees: { frequency: 1, size: 1, richness: 1 }, + "enemy-base": { frequency: 1, size: 1, richness: 1 } + } + }, + "rail-world": { + ...getDefaultMapGenSettings(), + autoplace_controls: { + coal: { frequency: 0.33, size: 3, richness: 1 }, + stone: { frequency: 0.33, size: 3, richness: 1 }, + "copper-ore": { frequency: 0.33, size: 3, richness: 1 }, + "iron-ore": { frequency: 0.33, size: 3, richness: 1 }, + "uranium-ore": { frequency: 0.33, size: 3, richness: 1 }, + "crude-oil": { frequency: 0.33, size: 3, richness: 1 }, + trees: { frequency: 1, size: 1, richness: 1 }, + "enemy-base": { frequency: 0.5, size: 1, richness: 1 } + } + }, + "death-world": { + ...getDefaultMapGenSettings(), + autoplace_controls: { + coal: { frequency: 1, size: 1, richness: 1 }, + stone: { frequency: 1, size: 1, richness: 1 }, + "copper-ore": { frequency: 1, size: 1, richness: 1 }, + "iron-ore": { frequency: 1, size: 1, richness: 1 }, + "uranium-ore": { frequency: 1, size: 1, richness: 1 }, + "crude-oil": { frequency: 1, size: 1, richness: 1 }, + trees: { frequency: 1, size: 1, richness: 1 }, + "enemy-base": { frequency: 2, size: 2, richness: 1 } + } + }, + peaceful: { + ...getDefaultMapGenSettings(), + peaceful_mode: true + } +}; + +export function getPresetNames() { + return Object.keys(FACTORIO_PRESETS); +} + +export function getPreset(name) { + return FACTORIO_PRESETS[name] || FACTORIO_PRESETS.default; +} diff --git a/gsm-backend/services/prometheus.js b/gsm-backend/services/prometheus.js new file mode 100644 index 0000000..a2ad1a7 --- /dev/null +++ b/gsm-backend/services/prometheus.js @@ -0,0 +1,112 @@ +import fetch from "node-fetch"; + +const PROMETHEUS_URL = "http://localhost:9090"; + +const SERVER_JOBS = { + "vrising": "vrising", + "factorio": "factorio", + "minecraft": "minecraft", + "zomboid": "zomboid", + "palworld": "palworld", + "terraria": "terraria", + "openttd": "openttd" +}; + +export async function queryPrometheus(query) { + const url = `${PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`; + const res = await fetch(url); + const data = await res.json(); + if (data.status !== "success") { + throw new Error(`Prometheus query failed: ${data.error}`); + } + return data.data.result; +} + +export async function queryPrometheusRange(query, start, end, step) { + const url = `${PROMETHEUS_URL}/api/v1/query_range?query=${encodeURIComponent(query)}&start=${start}&end=${end}&step=${step}`; + const res = await fetch(url); + const data = await res.json(); + if (data.status !== "success") { + throw new Error(`Prometheus query failed: ${data.error}`); + } + return data.data.result; +} + +export async function getServerMetricsHistory(serverId, range = "1h") { + const job = SERVER_JOBS[serverId]; + if (!job) { + throw new Error(`Unknown server ID: ${serverId}`); + } + + const end = Math.floor(Date.now() / 1000); + let duration, step; + switch (range) { + case "15m": duration = 15 * 60; step = 15; break; + case "1h": duration = 60 * 60; step = 60; break; + case "6h": duration = 6 * 60 * 60; step = 300; break; + case "24h": duration = 24 * 60 * 60; step = 900; break; + default: duration = 60 * 60; step = 60; + } + const start = end - duration; + + const cpuQuery = `100 - (avg by(instance) (irate(node_cpu_seconds_total{job="${job}",mode="idle"}[5m])) * 100)`; + const memQuery = `100 * (1 - ((node_memory_MemAvailable_bytes{job="${job}"} or node_memory_MemFree_bytes{job="${job}"}) / node_memory_MemTotal_bytes{job="${job}"}))`; + const netRxQuery = `sum(irate(node_network_receive_bytes_total{job="${job}",device!~"lo|veth.*|docker.*|br-.*"}[5m]))`; + const netTxQuery = `sum(irate(node_network_transmit_bytes_total{job="${job}",device!~"lo|veth.*|docker.*|br-.*"}[5m]))`; + + try { + const [cpuResult, memResult, netRxResult, netTxResult] = await Promise.all([ + queryPrometheusRange(cpuQuery, start, end, step), + queryPrometheusRange(memQuery, start, end, step), + queryPrometheusRange(netRxQuery, start, end, step), + queryPrometheusRange(netTxQuery, start, end, step) + ]); + + const cpu = cpuResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || []; + const memory = memResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || []; + const networkRx = netRxResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || []; + const networkTx = netTxResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || []; + + return { cpu, memory, networkRx, networkTx }; + } catch (error) { + console.error("Prometheus query error:", error); + return { cpu: [], memory: [], networkRx: [], networkTx: [] }; + } +} + +export async function getCurrentMetrics(serverId) { + const job = SERVER_JOBS[serverId]; + if (!job) { + throw new Error(`Unknown server ID: ${serverId}`); + } + + const cpuQuery = `100 - (avg by(instance) (irate(node_cpu_seconds_total{job="${job}",mode="idle"}[5m])) * 100)`; + const memPercentQuery = `100 * (1 - ((node_memory_MemAvailable_bytes{job="${job}"} or node_memory_MemFree_bytes{job="${job}"}) / node_memory_MemTotal_bytes{job="${job}"}))`; + const memUsedQuery = `node_memory_MemTotal_bytes{job="${job}"} - (node_memory_MemAvailable_bytes{job="${job}"} or node_memory_MemFree_bytes{job="${job}"})`; + const memTotalQuery = `node_memory_MemTotal_bytes{job="${job}"}`; + const uptimeQuery = `node_time_seconds{job="${job}"} - node_boot_time_seconds{job="${job}"}`; + const cpuCoresQuery = `count(node_cpu_seconds_total{job="${job}",mode="idle"})`; + + try { + const [cpuResult, memPercentResult, memUsedResult, memTotalResult, uptimeResult, cpuCoresResult] = await Promise.all([ + queryPrometheus(cpuQuery), + queryPrometheus(memPercentQuery), + queryPrometheus(memUsedQuery), + queryPrometheus(memTotalQuery), + queryPrometheus(uptimeQuery), + queryPrometheus(cpuCoresQuery) + ]); + + return { + cpu: parseFloat(cpuResult[0]?.value?.[1]) || 0, + memory: parseFloat(memPercentResult[0]?.value?.[1]) || 0, + memoryUsed: parseFloat(memUsedResult[0]?.value?.[1]) || 0, + memoryTotal: parseFloat(memTotalResult[0]?.value?.[1]) || 0, + uptime: parseFloat(uptimeResult[0]?.value?.[1]) || 0, + cpuCores: parseInt(cpuCoresResult[0]?.value?.[1]) || 1 + }; + } catch (error) { + console.error("Prometheus current metrics error:", error); + return { cpu: 0, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0, cpuCores: 1 }; + } +} diff --git a/gsm-backend/services/ssh.js b/gsm-backend/services/ssh.js index fc34ca9..1b37828 100644 --- a/gsm-backend/services/ssh.js +++ b/gsm-backend/services/ssh.js @@ -82,6 +82,18 @@ export async function getServerStatus(server) { if (status === 'activating' || status === 'reloading') return 'starting'; if (status === 'deactivating') return 'stopping'; return 'offline'; + } else if (server.runtime === 'pm2') { + const nvmPrefix = "source ~/.nvm/nvm.sh && "; + const result = await ssh.execCommand(nvmPrefix + "pm2 jlist"); + try { + const processes = JSON.parse(result.stdout); + const proc = processes.find(p => p.name === server.serviceName); + if (!proc) return "offline"; + if (proc.pm2_env.status === "online") return "online"; + if (proc.pm2_env.status === "launching") return "starting"; + if (proc.pm2_env.status === "stopping") return "stopping"; + return "offline"; + } catch { return "offline"; } } else { const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`); if (result.code === 0) { @@ -126,7 +138,10 @@ export async function startServer(server, options = {}) { await ssh.execCommand(`docker start ${server.containerName}`); } } else if (server.runtime === 'systemd') { - await ssh.execCommand(`systemctl start ${server.serviceName}`); + const sudoCmd = server.external ? "sudo " : ""; await ssh.execCommand(`${sudoCmd}systemctl start ${server.serviceName}`); + } else if (server.runtime === 'pm2') { + const nvmPrefix = "source ~/.nvm/nvm.sh && "; + await ssh.execCommand(nvmPrefix + "pm2 start " + server.serviceName); } else { await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`); await new Promise(resolve => setTimeout(resolve, 1000)); @@ -140,7 +155,10 @@ export async function stopServer(server) { if (server.runtime === 'docker') { await ssh.execCommand(`docker stop ${server.containerName}`); } else if (server.runtime === 'systemd') { - await ssh.execCommand(`systemctl stop ${server.serviceName}`); + const sudoCmd2 = server.external ? "sudo " : ""; await ssh.execCommand(`${sudoCmd2}systemctl stop ${server.serviceName}`); + } else if (server.runtime === 'pm2') { + const nvmPrefix = "source ~/.nvm/nvm.sh && "; + await ssh.execCommand(nvmPrefix + "pm2 stop " + server.serviceName); } else { // Different stop commands per server type const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop'; @@ -176,6 +194,10 @@ export async function getConsoleLog(server, lines = 50) { } else if (server.runtime === 'systemd') { const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`); return result.stdout || result.stderr; + } else if (server.runtime === 'pm2') { + const nvmPrefix = "source ~/.nvm/nvm.sh && "; + const result = await ssh.execCommand(nvmPrefix + "pm2 logs " + server.serviceName + " --lines " + lines + " --nostream"); + return result.stdout || result.stderr; } else if (server.logFile) { const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`); return result.stdout; @@ -203,6 +225,17 @@ export async function getProcessUptime(server) { if (!isNaN(startEpoch)) { return Math.floor(Date.now() / 1000) - startEpoch; } + } else if (server.runtime === "pm2") { + const nvmPrefix = "source ~/.nvm/nvm.sh && "; + const result = await ssh.execCommand(nvmPrefix + "pm2 jlist"); + try { + const processes = JSON.parse(result.stdout); + const proc = processes.find(p => p.name === server.serviceName); + if (proc && proc.pm2_env.pm_uptime) { + return Math.floor((Date.now() - proc.pm2_env.pm_uptime) / 1000); + } + } catch {} + return 0; } else { const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`); const uptime = parseInt(result.stdout.trim()); @@ -491,3 +524,67 @@ export async function writePalworldConfig(server, filename, content) { return true; } + +// ============ TERRARIA CONFIG ============ +const TERRARIA_CONFIG_PATH = "/home/terraria/serverconfig.txt"; + +export async function readTerrariaConfig(server) { + const ssh = await getConnection(server.host, server.sshUser); + const result = await ssh.execCommand(`cat ${TERRARIA_CONFIG_PATH}`); + + if (result.code !== 0) { + throw new Error(result.stderr || "Failed to read config file"); + } + + return result.stdout; +} + +export async function writeTerrariaConfig(server, content) { + const ssh = await getConnection(server.host, server.sshUser); + + // Create backup + const backupName = `serverconfig.txt.backup.${Date.now()}`; + await ssh.execCommand(`cp ${TERRARIA_CONFIG_PATH} /home/terraria/${backupName} 2>/dev/null || true`); + + // Write file using sftp + const sftp = await ssh.requestSFTP(); + + await new Promise((resolve, reject) => { + sftp.writeFile(TERRARIA_CONFIG_PATH, content, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + // Clean up old backups (keep last 5) + await ssh.execCommand(`ls -t /home/terraria/serverconfig.txt.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`); + + return true; +} + +// OpenTTD Config +const OPENTTD_CONFIG_PATH = "/opt/openttd/.openttd/openttd.cfg"; + +export async function readOpenTTDConfig(server) { + const ssh = await getConnection(server.host, server.sshUser); + const result = await ssh.execCommand(`cat ${OPENTTD_CONFIG_PATH}`); + if (result.code !== 0) { + throw new Error(result.stderr || "Failed to read config file"); + } + return result.stdout; +} + +export async function writeOpenTTDConfig(server, content) { + const ssh = await getConnection(server.host, server.sshUser); + const backupName = `openttd.cfg.backup.${Date.now()}`; + await ssh.execCommand(`cp ${OPENTTD_CONFIG_PATH} /opt/openttd/.openttd/${backupName} 2>/dev/null || true`); + const sftp = await ssh.requestSFTP(); + await new Promise((resolve, reject) => { + sftp.writeFile(OPENTTD_CONFIG_PATH, content, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + await ssh.execCommand(`ls -t /opt/openttd/.openttd/openttd.cfg.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`); + return true; +} diff --git a/gsm-backend/services/ssh.js.bak b/gsm-backend/services/ssh.js.bak new file mode 100644 index 0000000..fc34ca9 --- /dev/null +++ b/gsm-backend/services/ssh.js.bak @@ -0,0 +1,493 @@ +import { NodeSSH } from "node-ssh"; +import { readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const sshConnections = new Map(); +const failedHosts = new Map(); // Cache failed connections +const FAILED_HOST_TTL = 60000; // 60 seconds before retry +const SSH_TIMEOUT = 5000; + +function loadConfig() { + return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8")); +} + +// Check if host is marked as failed (non-blocking) +export function isHostFailed(host, username = "root") { + const key = username + "@" + host; + const failedAt = failedHosts.get(key); + return failedAt && Date.now() - failedAt < FAILED_HOST_TTL; +} + +// Mark host as failed +export function markHostFailed(host, username = "root") { + const key = username + "@" + host; + failedHosts.set(key, Date.now()); +} + +// Clear failed status +export function clearHostFailed(host, username = "root") { + const key = username + "@" + host; + failedHosts.delete(key); +} + +async function getConnection(host, username = "root") { + const key = username + "@" + host; + + // Check if host recently failed - throw immediately + if (isHostFailed(host, username)) { + throw new Error("Host recently unreachable"); + } + + if (sshConnections.has(key)) { + const conn = sshConnections.get(key); + if (conn.isConnected()) return conn; + sshConnections.delete(key); + } + + const ssh = new NodeSSH(); + try { + await ssh.connect({ + host, + username, + privateKeyPath: "/root/.ssh/id_ed25519", + readyTimeout: SSH_TIMEOUT + }); + clearHostFailed(host, username); + sshConnections.set(key, ssh); + return ssh; + } catch (err) { + markHostFailed(host, username); + throw err; + } +} + +// Returns: "online", "starting", "stopping", "offline" +export async function getServerStatus(server) { + try { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === 'docker') { + const result = await ssh.execCommand(`docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null`); + const status = result.stdout.trim(); + if (status === 'running') return 'online'; + if (status === 'restarting' || status === 'created') return 'starting'; + if (status === 'removing' || status === 'paused') return 'stopping'; + return 'offline'; + } else if (server.runtime === 'systemd') { + const result = await ssh.execCommand(`systemctl is-active ${server.serviceName}`); + const status = result.stdout.trim(); + if (status === 'active') return 'online'; + if (status === 'activating' || status === 'reloading') return 'starting'; + if (status === 'deactivating') return 'stopping'; + return 'offline'; + } else { + const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`); + if (result.code === 0) { + const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(screen -ls | grep "\\.${server.screenName}" | awk '{print $1}' | cut -d. -f1) 2>/dev/null | head -1`); + const uptime = parseInt(uptimeResult.stdout.trim()) || 999; + if (uptime < 60) return 'starting'; + return 'online'; + } + return 'offline'; + } + } catch (err) { + console.error(`Failed to get status for ${server.id}:`, err.message); + return 'offline'; + } +} + +export async function startServer(server, options = {}) { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === 'docker') { + // Factorio with specific save + if (server.type === 'factorio' && options.save) { + const saveName = options.save.endsWith('.zip') ? options.save.replace('.zip', '') : options.save; + // Stop and remove existing container + await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`); + await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`); + // Start with specific save + await ssh.execCommand(` + docker run -d \ + --name ${server.containerName} \ + -p 34197:34197/udp \ + -p 27015:27015/tcp \ + -v /srv/docker/factorio/data:/factorio \ + -e SAVE_NAME=${saveName} -e LOAD_LATEST_SAVE=false \ + -e TZ=Europe/Berlin \ + -e PUID=845 \ + -e PGID=845 \ + --restart=unless-stopped \ + factoriotools/factorio + `); + } else { + await ssh.execCommand(`docker start ${server.containerName}`); + } + } else if (server.runtime === 'systemd') { + await ssh.execCommand(`systemctl start ${server.serviceName}`); + } else { + await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`); + await new Promise(resolve => setTimeout(resolve, 1000)); + await ssh.execCommand(`cd ${server.workDir} && screen -dmS ${server.screenName} ${server.startCmd}`); + } +} + +export async function stopServer(server) { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === 'docker') { + await ssh.execCommand(`docker stop ${server.containerName}`); + } else if (server.runtime === 'systemd') { + await ssh.execCommand(`systemctl stop ${server.serviceName}`); + } else { + // Different stop commands per server type + const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop'; + await ssh.execCommand(`screen -S ${server.screenName} -p 0 -X stuff '${stopCmd}\n'`); + + for (let i = 0; i < 30; i++) { + await new Promise(resolve => setTimeout(resolve, 2000)); + const check = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`); + if (check.code !== 0) { + console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`); + return; + } + } + + console.log(`Force killing ${server.id} after timeout`); + await ssh.execCommand(`screen -S ${server.screenName} -X quit`); + await new Promise(resolve => setTimeout(resolve, 2000)); + } +} + +export async function restartServer(server) { + await stopServer(server); + await new Promise(resolve => setTimeout(resolve, 2000)); + await startServer(server); +} + +export async function getConsoleLog(server, lines = 50) { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === 'docker') { + const result = await ssh.execCommand(`/usr/local/bin/docker-logs-tz ${server.containerName} ${lines}`); + return result.stdout || result.stderr; + } else if (server.runtime === 'systemd') { + const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`); + return result.stdout || result.stderr; + } else if (server.logFile) { + const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`); + return result.stdout; + } else { + const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/latest.log 2>/dev/null || echo No log file found`); + return result.stdout; + } +} + +export async function getProcessUptime(server) { + try { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === "docker") { + const result = await ssh.execCommand(`docker inspect --format="{{.State.StartedAt}}" ${server.containerName} 2>/dev/null`); + if (result.stdout.trim()) { + const startTime = new Date(result.stdout.trim()); + if (!isNaN(startTime.getTime())) { + return Math.floor((Date.now() - startTime.getTime()) / 1000); + } + } + } else if (server.runtime === "systemd") { + const result = await ssh.execCommand(`systemctl show ${server.serviceName} --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null`); + const startEpoch = parseInt(result.stdout.trim()); + if (!isNaN(startEpoch)) { + return Math.floor(Date.now() / 1000) - startEpoch; + } + } else { + const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`); + const uptime = parseInt(result.stdout.trim()); + if (!isNaN(uptime)) return uptime; + } + return 0; + } catch (err) { + console.error(`Failed to get uptime for ${server.id}:`, err.message); + return 0; + } +} + +// ============ FACTORIO-SPECIFIC FUNCTIONS ============ + +export async function listFactorioSaves(server) { + const ssh = await getConnection(server.host, server.sshUser); + const result = await ssh.execCommand('ls -la /srv/docker/factorio/data/saves/*.zip 2>/dev/null'); + + if (result.code !== 0 || !result.stdout.trim()) { + return []; + } + + const saves = []; + const lines = result.stdout.trim().split('\n'); + + for (const line of lines) { + const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/); + if (match) { + const filename = match[7].split('/').pop(); + // Skip autosaves + if (filename.startsWith('_autosave')) continue; + + const size = match[5]; + const modified = match[6]; + + saves.push({ + name: filename.replace('.zip', ''), + filename, + size, + modified + }); + } + } + + return saves; +} + +export async function deleteFactorioSave(server, saveName) { + const ssh = await getConnection(server.host, server.sshUser); + const filename = saveName.endsWith('.zip') ? saveName : `${saveName}.zip`; + + // Security check - prevent path traversal + if (filename.includes('/') || filename.includes('..')) { + throw new Error('Invalid save name'); + } + + const result = await ssh.execCommand(`rm /srv/docker/factorio/data/saves/${filename}`); + if (result.code !== 0) { + throw new Error(result.stderr || 'Failed to delete save'); + } +} + +export async function createFactorioWorld(server, saveName, settings) { + const ssh = await getConnection(server.host, server.sshUser); + + // Security check + if (saveName.includes("/") || saveName.includes("..") || saveName.includes(" ")) { + throw new Error("Invalid save name - no spaces or special characters allowed"); + } + + // Write map-gen-settings.json + const settingsJson = JSON.stringify(settings, null, 2); + await ssh.execCommand(`cat > /srv/docker/factorio/data/config/map-gen-settings.json << 'SETTINGSEOF' +${settingsJson} +SETTINGSEOF`); + + // Create new world using --create flag (correct method) + console.log(`Creating new Factorio world: ${saveName}`); + const createResult = await ssh.execCommand(` + docker run --rm \ + -v /srv/docker/factorio/data:/factorio \ + factoriotools/factorio \ + /opt/factorio/bin/x64/factorio \ + --create /factorio/saves/${saveName}.zip \ + --map-gen-settings /factorio/config/map-gen-settings.json + `, { execOptions: { timeout: 120000 } }); + + console.log("World creation output:", createResult.stdout, createResult.stderr); + + // Verify save was created + const checkResult = await ssh.execCommand(`ls /srv/docker/factorio/data/saves/${saveName}.zip`); + if (checkResult.code !== 0) { + throw new Error("World creation failed - save file not found. Output: " + createResult.stderr); + } + + return true; +} + +export async function getFactorioCurrentSave(server) { + const ssh = await getConnection(server.host, server.sshUser); + + // Check if container has SAVE_NAME env set + const envResult = await ssh.execCommand(`docker inspect --format="{{range .Config.Env}}{{println .}}{{end}}" ${server.containerName} 2>/dev/null | grep "^SAVE_NAME="`); + if (envResult.stdout.trim()) { + const saveName = envResult.stdout.trim().replace("SAVE_NAME=", ""); + return { save: saveName, source: "configured" }; + } + + // Otherwise find newest save file + const result = await ssh.execCommand("ls -t /srv/docker/factorio/data/saves/*.zip 2>/dev/null | head -1"); + if (result.stdout.trim()) { + const filename = result.stdout.trim().split("/").pop().replace(".zip", ""); + return { save: filename, source: "newest" }; + } + + return { save: null, source: null }; +} + + + +// ============ ZOMBOID CONFIG FUNCTIONS ============ + +const ZOMBOID_CONFIG_PATH = "/home/pzuser/Zomboid/Server"; +const ALLOWED_CONFIG_FILES = ["Project.ini", "Project_SandboxVars.lua", "Project_spawnpoints.lua", "Project_spawnregions.lua"]; + +export async function listZomboidConfigs(server) { + const ssh = await getConnection(server.host, server.sshUser); + const cmd = `ls -la ${ZOMBOID_CONFIG_PATH}/*.ini ${ZOMBOID_CONFIG_PATH}/*.lua 2>/dev/null`; + const result = await ssh.execCommand(cmd); + + if (result.code !== 0 || !result.stdout.trim()) { + return []; + } + + const files = []; + const lines = result.stdout.trim().split("\n"); + + for (const line of lines) { + const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/); + if (match) { + const fullPath = match[7]; + const filename = fullPath.split("/").pop(); + + if (!ALLOWED_CONFIG_FILES.includes(filename)) continue; + + files.push({ + filename, + size: parseInt(match[5]), + modified: match[6] + }); + } + } + + return files; +} + +export async function readZomboidConfig(server, filename) { + if (!ALLOWED_CONFIG_FILES.includes(filename)) { + throw new Error("File not allowed"); + } + if (filename.includes("/") || filename.includes("..")) { + throw new Error("Invalid filename"); + } + + const ssh = await getConnection(server.host, server.sshUser); + const result = await ssh.execCommand(`cat ${ZOMBOID_CONFIG_PATH}/${filename}`); + + if (result.code !== 0) { + throw new Error(result.stderr || "Failed to read config file"); + } + + return result.stdout; +} + +export async function writeZomboidConfig(server, filename, content) { + if (!ALLOWED_CONFIG_FILES.includes(filename)) { + throw new Error("File not allowed"); + } + if (filename.includes("/") || filename.includes("..")) { + throw new Error("Invalid filename"); + } + + const ssh = await getConnection(server.host, server.sshUser); + + // Create backup + const backupName = `${filename}.backup.${Date.now()}`; + await ssh.execCommand(`cp ${ZOMBOID_CONFIG_PATH}/${filename} ${ZOMBOID_CONFIG_PATH}/${backupName} 2>/dev/null || true`); + + // Write file using sftp + const sftp = await ssh.requestSFTP(); + const filePath = `${ZOMBOID_CONFIG_PATH}/${filename}`; + + await new Promise((resolve, reject) => { + sftp.writeFile(filePath, content, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + // Clean up old backups (keep last 5) + await ssh.execCommand(`ls -t ${ZOMBOID_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`); + + return true; +} + +// ============ PALWORLD CONFIG ============ +const PALWORLD_CONFIG_PATH = "/opt/palworld/Pal/Saved/Config/LinuxServer"; +const PALWORLD_ALLOWED_FILES = ["PalWorldSettings.ini", "Engine.ini", "GameUserSettings.ini"]; + +export async function listPalworldConfigs(server) { + const ssh = await getConnection(server.host); + const cmd = `ls -la ${PALWORLD_CONFIG_PATH}/*.ini 2>/dev/null`; + const result = await ssh.execCommand(cmd); + + if (result.code !== 0 || !result.stdout.trim()) { + return []; + } + + const files = []; + const lines = result.stdout.trim().split("\n"); + + for (const line of lines) { + const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/); + if (match) { + const fullPath = match[7]; + const filename = fullPath.split("/").pop(); + + if (!PALWORLD_ALLOWED_FILES.includes(filename)) continue; + + files.push({ + filename, + size: parseInt(match[5]), + modified: match[6] + }); + } + } + + return files; +} + +export async function readPalworldConfig(server, filename) { + if (!PALWORLD_ALLOWED_FILES.includes(filename)) { + throw new Error("File not allowed"); + } + if (filename.includes("/") || filename.includes("..")) { + throw new Error("Invalid filename"); + } + + const ssh = await getConnection(server.host); + const result = await ssh.execCommand(`cat ${PALWORLD_CONFIG_PATH}/${filename}`); + + if (result.code !== 0) { + throw new Error(result.stderr || "Failed to read config file"); + } + + return result.stdout; +} + +export async function writePalworldConfig(server, filename, content) { + if (!PALWORLD_ALLOWED_FILES.includes(filename)) { + throw new Error("File not allowed"); + } + if (filename.includes("/") || filename.includes("..")) { + throw new Error("Invalid filename"); + } + + const ssh = await getConnection(server.host); + + // Create backup + const backupName = `${filename}.backup.${Date.now()}`; + await ssh.execCommand(`cp ${PALWORLD_CONFIG_PATH}/${filename} ${PALWORLD_CONFIG_PATH}/${backupName} 2>/dev/null || true`); + + // Write file using sftp + const sftp = await ssh.requestSFTP(); + const filePath = `${PALWORLD_CONFIG_PATH}/${filename}`; + + await new Promise((resolve, reject) => { + sftp.writeFile(filePath, content, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + // Clean up old backups (keep last 5) + await ssh.execCommand(`ls -t ${PALWORLD_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`); + + return true; +} diff --git a/gsm-backend/services/ssh.js.bak2 b/gsm-backend/services/ssh.js.bak2 new file mode 100644 index 0000000..fc34ca9 --- /dev/null +++ b/gsm-backend/services/ssh.js.bak2 @@ -0,0 +1,493 @@ +import { NodeSSH } from "node-ssh"; +import { readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const sshConnections = new Map(); +const failedHosts = new Map(); // Cache failed connections +const FAILED_HOST_TTL = 60000; // 60 seconds before retry +const SSH_TIMEOUT = 5000; + +function loadConfig() { + return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8")); +} + +// Check if host is marked as failed (non-blocking) +export function isHostFailed(host, username = "root") { + const key = username + "@" + host; + const failedAt = failedHosts.get(key); + return failedAt && Date.now() - failedAt < FAILED_HOST_TTL; +} + +// Mark host as failed +export function markHostFailed(host, username = "root") { + const key = username + "@" + host; + failedHosts.set(key, Date.now()); +} + +// Clear failed status +export function clearHostFailed(host, username = "root") { + const key = username + "@" + host; + failedHosts.delete(key); +} + +async function getConnection(host, username = "root") { + const key = username + "@" + host; + + // Check if host recently failed - throw immediately + if (isHostFailed(host, username)) { + throw new Error("Host recently unreachable"); + } + + if (sshConnections.has(key)) { + const conn = sshConnections.get(key); + if (conn.isConnected()) return conn; + sshConnections.delete(key); + } + + const ssh = new NodeSSH(); + try { + await ssh.connect({ + host, + username, + privateKeyPath: "/root/.ssh/id_ed25519", + readyTimeout: SSH_TIMEOUT + }); + clearHostFailed(host, username); + sshConnections.set(key, ssh); + return ssh; + } catch (err) { + markHostFailed(host, username); + throw err; + } +} + +// Returns: "online", "starting", "stopping", "offline" +export async function getServerStatus(server) { + try { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === 'docker') { + const result = await ssh.execCommand(`docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null`); + const status = result.stdout.trim(); + if (status === 'running') return 'online'; + if (status === 'restarting' || status === 'created') return 'starting'; + if (status === 'removing' || status === 'paused') return 'stopping'; + return 'offline'; + } else if (server.runtime === 'systemd') { + const result = await ssh.execCommand(`systemctl is-active ${server.serviceName}`); + const status = result.stdout.trim(); + if (status === 'active') return 'online'; + if (status === 'activating' || status === 'reloading') return 'starting'; + if (status === 'deactivating') return 'stopping'; + return 'offline'; + } else { + const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`); + if (result.code === 0) { + const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(screen -ls | grep "\\.${server.screenName}" | awk '{print $1}' | cut -d. -f1) 2>/dev/null | head -1`); + const uptime = parseInt(uptimeResult.stdout.trim()) || 999; + if (uptime < 60) return 'starting'; + return 'online'; + } + return 'offline'; + } + } catch (err) { + console.error(`Failed to get status for ${server.id}:`, err.message); + return 'offline'; + } +} + +export async function startServer(server, options = {}) { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === 'docker') { + // Factorio with specific save + if (server.type === 'factorio' && options.save) { + const saveName = options.save.endsWith('.zip') ? options.save.replace('.zip', '') : options.save; + // Stop and remove existing container + await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`); + await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`); + // Start with specific save + await ssh.execCommand(` + docker run -d \ + --name ${server.containerName} \ + -p 34197:34197/udp \ + -p 27015:27015/tcp \ + -v /srv/docker/factorio/data:/factorio \ + -e SAVE_NAME=${saveName} -e LOAD_LATEST_SAVE=false \ + -e TZ=Europe/Berlin \ + -e PUID=845 \ + -e PGID=845 \ + --restart=unless-stopped \ + factoriotools/factorio + `); + } else { + await ssh.execCommand(`docker start ${server.containerName}`); + } + } else if (server.runtime === 'systemd') { + await ssh.execCommand(`systemctl start ${server.serviceName}`); + } else { + await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`); + await new Promise(resolve => setTimeout(resolve, 1000)); + await ssh.execCommand(`cd ${server.workDir} && screen -dmS ${server.screenName} ${server.startCmd}`); + } +} + +export async function stopServer(server) { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === 'docker') { + await ssh.execCommand(`docker stop ${server.containerName}`); + } else if (server.runtime === 'systemd') { + await ssh.execCommand(`systemctl stop ${server.serviceName}`); + } else { + // Different stop commands per server type + const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop'; + await ssh.execCommand(`screen -S ${server.screenName} -p 0 -X stuff '${stopCmd}\n'`); + + for (let i = 0; i < 30; i++) { + await new Promise(resolve => setTimeout(resolve, 2000)); + const check = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`); + if (check.code !== 0) { + console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`); + return; + } + } + + console.log(`Force killing ${server.id} after timeout`); + await ssh.execCommand(`screen -S ${server.screenName} -X quit`); + await new Promise(resolve => setTimeout(resolve, 2000)); + } +} + +export async function restartServer(server) { + await stopServer(server); + await new Promise(resolve => setTimeout(resolve, 2000)); + await startServer(server); +} + +export async function getConsoleLog(server, lines = 50) { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === 'docker') { + const result = await ssh.execCommand(`/usr/local/bin/docker-logs-tz ${server.containerName} ${lines}`); + return result.stdout || result.stderr; + } else if (server.runtime === 'systemd') { + const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`); + return result.stdout || result.stderr; + } else if (server.logFile) { + const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`); + return result.stdout; + } else { + const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/latest.log 2>/dev/null || echo No log file found`); + return result.stdout; + } +} + +export async function getProcessUptime(server) { + try { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === "docker") { + const result = await ssh.execCommand(`docker inspect --format="{{.State.StartedAt}}" ${server.containerName} 2>/dev/null`); + if (result.stdout.trim()) { + const startTime = new Date(result.stdout.trim()); + if (!isNaN(startTime.getTime())) { + return Math.floor((Date.now() - startTime.getTime()) / 1000); + } + } + } else if (server.runtime === "systemd") { + const result = await ssh.execCommand(`systemctl show ${server.serviceName} --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null`); + const startEpoch = parseInt(result.stdout.trim()); + if (!isNaN(startEpoch)) { + return Math.floor(Date.now() / 1000) - startEpoch; + } + } else { + const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`); + const uptime = parseInt(result.stdout.trim()); + if (!isNaN(uptime)) return uptime; + } + return 0; + } catch (err) { + console.error(`Failed to get uptime for ${server.id}:`, err.message); + return 0; + } +} + +// ============ FACTORIO-SPECIFIC FUNCTIONS ============ + +export async function listFactorioSaves(server) { + const ssh = await getConnection(server.host, server.sshUser); + const result = await ssh.execCommand('ls -la /srv/docker/factorio/data/saves/*.zip 2>/dev/null'); + + if (result.code !== 0 || !result.stdout.trim()) { + return []; + } + + const saves = []; + const lines = result.stdout.trim().split('\n'); + + for (const line of lines) { + const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/); + if (match) { + const filename = match[7].split('/').pop(); + // Skip autosaves + if (filename.startsWith('_autosave')) continue; + + const size = match[5]; + const modified = match[6]; + + saves.push({ + name: filename.replace('.zip', ''), + filename, + size, + modified + }); + } + } + + return saves; +} + +export async function deleteFactorioSave(server, saveName) { + const ssh = await getConnection(server.host, server.sshUser); + const filename = saveName.endsWith('.zip') ? saveName : `${saveName}.zip`; + + // Security check - prevent path traversal + if (filename.includes('/') || filename.includes('..')) { + throw new Error('Invalid save name'); + } + + const result = await ssh.execCommand(`rm /srv/docker/factorio/data/saves/${filename}`); + if (result.code !== 0) { + throw new Error(result.stderr || 'Failed to delete save'); + } +} + +export async function createFactorioWorld(server, saveName, settings) { + const ssh = await getConnection(server.host, server.sshUser); + + // Security check + if (saveName.includes("/") || saveName.includes("..") || saveName.includes(" ")) { + throw new Error("Invalid save name - no spaces or special characters allowed"); + } + + // Write map-gen-settings.json + const settingsJson = JSON.stringify(settings, null, 2); + await ssh.execCommand(`cat > /srv/docker/factorio/data/config/map-gen-settings.json << 'SETTINGSEOF' +${settingsJson} +SETTINGSEOF`); + + // Create new world using --create flag (correct method) + console.log(`Creating new Factorio world: ${saveName}`); + const createResult = await ssh.execCommand(` + docker run --rm \ + -v /srv/docker/factorio/data:/factorio \ + factoriotools/factorio \ + /opt/factorio/bin/x64/factorio \ + --create /factorio/saves/${saveName}.zip \ + --map-gen-settings /factorio/config/map-gen-settings.json + `, { execOptions: { timeout: 120000 } }); + + console.log("World creation output:", createResult.stdout, createResult.stderr); + + // Verify save was created + const checkResult = await ssh.execCommand(`ls /srv/docker/factorio/data/saves/${saveName}.zip`); + if (checkResult.code !== 0) { + throw new Error("World creation failed - save file not found. Output: " + createResult.stderr); + } + + return true; +} + +export async function getFactorioCurrentSave(server) { + const ssh = await getConnection(server.host, server.sshUser); + + // Check if container has SAVE_NAME env set + const envResult = await ssh.execCommand(`docker inspect --format="{{range .Config.Env}}{{println .}}{{end}}" ${server.containerName} 2>/dev/null | grep "^SAVE_NAME="`); + if (envResult.stdout.trim()) { + const saveName = envResult.stdout.trim().replace("SAVE_NAME=", ""); + return { save: saveName, source: "configured" }; + } + + // Otherwise find newest save file + const result = await ssh.execCommand("ls -t /srv/docker/factorio/data/saves/*.zip 2>/dev/null | head -1"); + if (result.stdout.trim()) { + const filename = result.stdout.trim().split("/").pop().replace(".zip", ""); + return { save: filename, source: "newest" }; + } + + return { save: null, source: null }; +} + + + +// ============ ZOMBOID CONFIG FUNCTIONS ============ + +const ZOMBOID_CONFIG_PATH = "/home/pzuser/Zomboid/Server"; +const ALLOWED_CONFIG_FILES = ["Project.ini", "Project_SandboxVars.lua", "Project_spawnpoints.lua", "Project_spawnregions.lua"]; + +export async function listZomboidConfigs(server) { + const ssh = await getConnection(server.host, server.sshUser); + const cmd = `ls -la ${ZOMBOID_CONFIG_PATH}/*.ini ${ZOMBOID_CONFIG_PATH}/*.lua 2>/dev/null`; + const result = await ssh.execCommand(cmd); + + if (result.code !== 0 || !result.stdout.trim()) { + return []; + } + + const files = []; + const lines = result.stdout.trim().split("\n"); + + for (const line of lines) { + const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/); + if (match) { + const fullPath = match[7]; + const filename = fullPath.split("/").pop(); + + if (!ALLOWED_CONFIG_FILES.includes(filename)) continue; + + files.push({ + filename, + size: parseInt(match[5]), + modified: match[6] + }); + } + } + + return files; +} + +export async function readZomboidConfig(server, filename) { + if (!ALLOWED_CONFIG_FILES.includes(filename)) { + throw new Error("File not allowed"); + } + if (filename.includes("/") || filename.includes("..")) { + throw new Error("Invalid filename"); + } + + const ssh = await getConnection(server.host, server.sshUser); + const result = await ssh.execCommand(`cat ${ZOMBOID_CONFIG_PATH}/${filename}`); + + if (result.code !== 0) { + throw new Error(result.stderr || "Failed to read config file"); + } + + return result.stdout; +} + +export async function writeZomboidConfig(server, filename, content) { + if (!ALLOWED_CONFIG_FILES.includes(filename)) { + throw new Error("File not allowed"); + } + if (filename.includes("/") || filename.includes("..")) { + throw new Error("Invalid filename"); + } + + const ssh = await getConnection(server.host, server.sshUser); + + // Create backup + const backupName = `${filename}.backup.${Date.now()}`; + await ssh.execCommand(`cp ${ZOMBOID_CONFIG_PATH}/${filename} ${ZOMBOID_CONFIG_PATH}/${backupName} 2>/dev/null || true`); + + // Write file using sftp + const sftp = await ssh.requestSFTP(); + const filePath = `${ZOMBOID_CONFIG_PATH}/${filename}`; + + await new Promise((resolve, reject) => { + sftp.writeFile(filePath, content, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + // Clean up old backups (keep last 5) + await ssh.execCommand(`ls -t ${ZOMBOID_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`); + + return true; +} + +// ============ PALWORLD CONFIG ============ +const PALWORLD_CONFIG_PATH = "/opt/palworld/Pal/Saved/Config/LinuxServer"; +const PALWORLD_ALLOWED_FILES = ["PalWorldSettings.ini", "Engine.ini", "GameUserSettings.ini"]; + +export async function listPalworldConfigs(server) { + const ssh = await getConnection(server.host); + const cmd = `ls -la ${PALWORLD_CONFIG_PATH}/*.ini 2>/dev/null`; + const result = await ssh.execCommand(cmd); + + if (result.code !== 0 || !result.stdout.trim()) { + return []; + } + + const files = []; + const lines = result.stdout.trim().split("\n"); + + for (const line of lines) { + const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/); + if (match) { + const fullPath = match[7]; + const filename = fullPath.split("/").pop(); + + if (!PALWORLD_ALLOWED_FILES.includes(filename)) continue; + + files.push({ + filename, + size: parseInt(match[5]), + modified: match[6] + }); + } + } + + return files; +} + +export async function readPalworldConfig(server, filename) { + if (!PALWORLD_ALLOWED_FILES.includes(filename)) { + throw new Error("File not allowed"); + } + if (filename.includes("/") || filename.includes("..")) { + throw new Error("Invalid filename"); + } + + const ssh = await getConnection(server.host); + const result = await ssh.execCommand(`cat ${PALWORLD_CONFIG_PATH}/${filename}`); + + if (result.code !== 0) { + throw new Error(result.stderr || "Failed to read config file"); + } + + return result.stdout; +} + +export async function writePalworldConfig(server, filename, content) { + if (!PALWORLD_ALLOWED_FILES.includes(filename)) { + throw new Error("File not allowed"); + } + if (filename.includes("/") || filename.includes("..")) { + throw new Error("Invalid filename"); + } + + const ssh = await getConnection(server.host); + + // Create backup + const backupName = `${filename}.backup.${Date.now()}`; + await ssh.execCommand(`cp ${PALWORLD_CONFIG_PATH}/${filename} ${PALWORLD_CONFIG_PATH}/${backupName} 2>/dev/null || true`); + + // Write file using sftp + const sftp = await ssh.requestSFTP(); + const filePath = `${PALWORLD_CONFIG_PATH}/${filename}`; + + await new Promise((resolve, reject) => { + sftp.writeFile(filePath, content, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + // Clean up old backups (keep last 5) + await ssh.execCommand(`ls -t ${PALWORLD_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`); + + return true; +} diff --git a/gsm-frontend/package-lock.json b/gsm-frontend/package-lock.json index c32afb4..b590168 100644 --- a/gsm-frontend/package-lock.json +++ b/gsm-frontend/package-lock.json @@ -78,6 +78,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1792,6 +1793,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1839,6 +1841,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1981,6 +1984,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2397,6 +2401,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3419,6 +3424,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3446,6 +3452,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3487,6 +3494,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3496,6 +3504,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3515,6 +3524,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -3615,7 +3625,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -3779,7 +3790,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -3918,6 +3930,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4039,6 +4052,7 @@ "integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/gsm-frontend/src/App.jsx b/gsm-frontend/src/App.jsx index 3741ce6..cfa4f80 100644 --- a/gsm-frontend/src/App.jsx +++ b/gsm-frontend/src/App.jsx @@ -3,6 +3,8 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { UserProvider } from './context/UserContext' import Dashboard from './pages/Dashboard' import ServerDetail from './pages/ServerDetail' +import LoginPage from './pages/LoginPage' +import AuthCallback from './pages/AuthCallback' export default function App() { const [token, setToken] = useState(localStorage.getItem('gsm_token')) @@ -18,11 +20,13 @@ export default function App() { } return ( - + - } /> - } /> + : } /> + : } /> + } /> + } /> } /> diff --git a/gsm-frontend/src/components/LoginModal.jsx b/gsm-frontend/src/components/LoginModal.jsx index 82bf883..5869e38 100644 --- a/gsm-frontend/src/components/LoginModal.jsx +++ b/gsm-frontend/src/components/LoginModal.jsx @@ -1,78 +1,36 @@ -import { useState } from 'react' -import { login } from '../api' +const API_URL = import.meta.env.VITE_API_URL || '/api' -export default function LoginModal({ onLogin, onClose }) { - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - - const handleSubmit = async (e) => { - e.preventDefault() - setError('') - setLoading(true) - - try { - const { token } = await login(username, password) - onLogin(token) - onClose() - } catch (err) { - setError(err.message || 'Login failed') - } finally { - setLoading(false) - } +export default function LoginModal({ onClose }) { + const handleDiscordLogin = () => { + window.location.href = `${API_URL}/auth/discord` } return (
e.stopPropagation()}>
-

Sign in

+

Anmelden

-
- {error && ( -
- {error} -
- )} +

+ Melde dich mit Discord an um erweiterte Funktionen zu nutzen. +

-
- - setUsername(e.target.value)} - className="input" - placeholder="Enter username" - required - autoFocus - /> -
- -
- - setPassword(e.target.value)} - className="input" - placeholder="Enter password" - required - /> -
- - -
+
diff --git a/gsm-frontend/src/components/UserManagement.jsx b/gsm-frontend/src/components/UserManagement.jsx index acf5b59..e6168f5 100644 --- a/gsm-frontend/src/components/UserManagement.jsx +++ b/gsm-frontend/src/components/UserManagement.jsx @@ -2,6 +2,10 @@ import { useState, useEffect } from 'react' import { useUser } from '../context/UserContext' import { getUsers } from '../api' +function getDiscordProfileUrl(discordId) { + return `https://discord.com/users/${discordId}` +} + export default function UserManagement({ onClose }) { const { token } = useUser() const [users, setUsers] = useState([]) @@ -66,26 +70,54 @@ export default function UserManagement({ onClose }) {
Loading users...
) : (
- {users.map((user) => ( -
- {user.username} -
-
{user.username}
-
- - {roleLabels[user.role]} - - {(user.discord_id || user.discordId) && ( - ID: {user.discord_id || user.discordId} + {users.map((user) => { + const discordId = user.discord_id || user.discordId + const profileUrl = discordId ? getDiscordProfileUrl(discordId) : null + + return ( +
+ {profileUrl ? ( + + {user.username} + + ) : ( + {user.username} + )} +
+ {profileUrl ? ( + + {user.username} + + ) : ( +
{user.username}
)} +
+ + {roleLabels[user.role]} + +
-
- ))} + ) + })}
)} diff --git a/gsm-frontend/src/pages/AuthCallback.jsx b/gsm-frontend/src/pages/AuthCallback.jsx new file mode 100644 index 0000000..0b936d3 --- /dev/null +++ b/gsm-frontend/src/pages/AuthCallback.jsx @@ -0,0 +1,28 @@ +import { useEffect } from 'react' +import { useSearchParams, useNavigate } from 'react-router-dom' + +export default function AuthCallback({ onLogin }) { + const [searchParams] = useSearchParams() + const navigate = useNavigate() + + useEffect(() => { + const token = searchParams.get('token') + + if (token) { + onLogin(token) + navigate('/', { replace: true }) + } else { + // No token received, redirect to login with error + navigate('/login?error=oauth_failed', { replace: true }) + } + }, [searchParams, onLogin, navigate]) + + return ( +
+
+
+

Anmeldung wird verarbeitet...

+
+
+ ) +} diff --git a/gsm-frontend/src/pages/Dashboard.jsx b/gsm-frontend/src/pages/Dashboard.jsx index 091e6b3..9dffb31 100644 --- a/gsm-frontend/src/pages/Dashboard.jsx +++ b/gsm-frontend/src/pages/Dashboard.jsx @@ -4,10 +4,10 @@ import { getServers, getAllDisplaySettings } from '../api' import { useUser } from '../context/UserContext' import ServerCard from '../components/ServerCard' import UserManagement from '../components/UserManagement' -import LoginModal from '../components/LoginModal' import ActivityLog from '../components/ActivityLog' +import LoginModal from '../components/LoginModal' -export default function Dashboard({ onLogin, onLogout }) { +export default function Dashboard({ onLogout }) { const navigate = useNavigate() const { user, token, loading: userLoading, isSuperadmin, role } = useUser() const [servers, setServers] = useState([]) @@ -15,10 +15,12 @@ export default function Dashboard({ onLogin, onLogout }) { const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [showUserMgmt, setShowUserMgmt] = useState(false) - const [showLogin, setShowLogin] = useState(false) const [showActivityLog, setShowActivityLog] = useState(false) + const [showLogin, setShowLogin] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const isGuest = user?.isGuest || role === 'guest' + const isAuthenticated = !!token const fetchServers = async () => { @@ -93,39 +95,53 @@ export default function Dashboard({ onLogin, onLogout }) { <> {/* Desktop Navigation */}
- {user?.avatar && user?.discordId && ( - Avatar - )} -
-
{user?.username}
-
{roleLabels[role]}
-
- {isSuperadmin && ( + {isGuest ? ( <> + Gast + + ) : ( + <> + {user?.avatar && user?.discordId && ( + Avatar + )} +
+
{user?.username}
+
{roleLabels[role]}
+
+ {isSuperadmin && ( + <> + + + + )} )} -
{/* Mobile Burger Button */} @@ -147,7 +163,7 @@ export default function Dashboard({ onLogin, onLogout }) { ) : ( + ) : ( <> + {isSuperadmin && ( + <> + + + + )} - )} -
)} @@ -274,7 +301,7 @@ export default function Dashboard({ onLogin, onLogout }) { setShowActivityLog(false)} /> )} {showLogin && ( - setShowLogin(false)} /> + setShowLogin(false)} /> )} ) diff --git a/gsm-frontend/src/pages/LoginPage.jsx b/gsm-frontend/src/pages/LoginPage.jsx new file mode 100644 index 0000000..1d26959 --- /dev/null +++ b/gsm-frontend/src/pages/LoginPage.jsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from 'react' +import { useSearchParams, useNavigate } from 'react-router-dom' +import { useUser } from '../context/UserContext' + +const API_URL = import.meta.env.VITE_API_URL || '/api' + +const ERROR_MESSAGES = { + discord_denied: 'Discord-Anmeldung wurde abgebrochen.', + no_code: 'Kein Autorisierungscode erhalten.', + not_in_guild: 'Du bist nicht Mitglied eines berechtigten Discord-Servers.', + oauth_failed: 'Discord-Anmeldung fehlgeschlagen. Bitte versuche es erneut.', +} + +export default function LoginPage({ onLogin }) { + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const { user, loading } = useUser() + const [error, setError] = useState(null) + const [guestLoading, setGuestLoading] = useState(false) + + useEffect(() => { + if (!loading && user) { + navigate('/', { replace: true }) + } + }, [user, loading, navigate]) + + useEffect(() => { + const errorCode = searchParams.get('error') + if (errorCode) { + setError(ERROR_MESSAGES[errorCode] || 'Ein unbekannter Fehler ist aufgetreten.') + } + }, [searchParams]) + + const handleDiscordLogin = () => { + window.location.href = `${API_URL}/auth/discord` + } + + const handleGuestLogin = async () => { + setGuestLoading(true) + try { + const res = await fetch(`${API_URL}/auth/guest`, { method: 'POST' }) + const data = await res.json() + if (data.token) { + onLogin(data.token) + navigate('/', { replace: true }) + } + } catch (err) { + setError('Gast-Anmeldung fehlgeschlagen.') + } finally { + setGuestLoading(false) + } + } + + if (loading) { + return ( +
+
Laden...
+
+ ) + } + + return ( +
+ {/* Pulsing Spotlight */} +
+ + + +
+ {/* Logo */} + + Zeasy + Zeasy + + +

Gameserver Management

+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+
+ ) +}