From 76762ab567fe438d2edee96cf95fb6cfaaf2e068 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Sun, 4 Oct 2026 18:46:08 +0000 Subject: [PATCH] Upload via GUI (143 Dateien) --- CHANGELOG.md | 29 + README.md | 12 +- .../obj/AeroMCSetup.csproj.nuget.dgspec.json | 2 +- installer/obj/Release/net48/AeroMC-Setup.exe | Bin 259072 -> 259072 bytes installer/obj/project.assets.json | 2 +- installer/obj/project.nuget.cache | 2 +- package-lock.json | 4 +- package.json | 2 +- .../aeromc-friends/aeromc-friends.php | 4 +- .../aeromc-friends/includes/routes.php | 2077 +++++++++-------- 10 files changed, 1096 insertions(+), 1038 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 878c4e9..8f22b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ Alle nennenswerten Änderungen an AeroMC werden hier festgehalten. +## [1.2.0] - 2026-10-04 + +### Neu +- Untergruppen (optional): im Feld „Gruppe“ mit „/“ trennen, z. B. „Modpacks/Survival“ – auch mehrere Ebenen tief. Untergruppen erscheinen eingerückt unter ihrer Gruppe, lassen sich einzeln einklappen und per Drag & Drop befüllen; die Anzahl an einer Gruppe zählt ihre Untergruppen mit +- Importe aus anderen Launchern zeigen jetzt genauen Fortschritt: Instanz x von y, übertragene GB, Prozent, Tempo, Restzeit und die gerade kopierte Datei +- Import abbrechen: fertig übernommene Instanzen bleiben erhalten, die gerade halb kopierte wird wieder entfernt +- Vor dem Kopieren prüft AeroMC, ob genug Speicherplatz frei ist, und nennt sonst benötigte und freie GB +- Konto-Menü oben rechts (wie in MultiMC): Ein Klick auf den Kontonamen klappt alle Konten mit Spielerkopf auf – das aktive ist markiert, ein Klick wechselt sofort. Dazu „Konto hinzufügen…“, „Skin ändern…“ und „Konten verwalten…“ + +### Geändert +- Plugin „AeroMC Freunde“ 1.5.1: merkt sich den von Mojang bestätigten Namen schon bei der Anmeldung und beim Senden einer Anfrage – Spieler sind damit per Name auffindbar, bevor sie zum ersten Mal über AeroMC gespielt haben (Server-Update empfohlen, der Launcher funktioniert auch mit 1.5.0) + +### Behoben +- Freundschaftsanfragen zeigten statt des Namens nur eine lange ID (UUID), wenn der Absender noch nie über AeroMC gespielt hatte – fehlende Namen holt der Launcher jetzt direkt bei Mojang (auch in der Freundesliste und bei gesendeten Anfragen) +- Große Importe (viele GB, z. B. 60 GB aus MultiMC/Prism, CurseForge, Modrinth App, Minecraft Launcher oder Technic) froren den Launcher komplett ein – kopiert wird jetzt im Hintergrund, der Launcher bleibt bedienbar +- Einzelne gesperrte Dateien (z. B. eine gerade geöffnete Welt) brechen den Import nicht mehr ab, sie werden übersprungen und gemeldet +- „Instanz kopieren“, „Löschen“ und das Ersetzen einer vorhandenen Instanz beim erneuten Import blockierten bei großen Instanzen den Launcher – das läuft jetzt im Hintergrund +- Der Spielerkopf oben neben dem Kontonamen und in der Kontoliste zeigte die 2. Skin-Ebene (Mütze, Haare, Kragen, Ärmel) nicht sichtbar – er wird jetzt selbst in 3D gezeichnet, die 2. Ebene steht wie im Spiel plastisch ab + +## [1.1.9] - 2026-10-04 + +### Neu +- Startanimation: Beim Öffnen dreht sich das AeroMC-Logo mit Schriftzug, danach baut sich der Launcher von der Mitte nach außen auf – Schriftzug passend zum Design (Minecraft mit Pixel-Schrift), nicht beim Start im Infobereich +- Lebendige Skin-Figur im Hintergrund: atmet, wiegt sich leicht, der Umhang schwingt, und der Kopf folgt dem Mauszeiger – pausiert, solange der Launcher minimiert oder im Hintergrund ist +- Fenster wie Optionen, Mods, Skin und Freunde wachsen beim Öffnen aus dem angeklickten Knopf heraus +- Instanzen blenden beim Öffnen des Launchers nacheinander ein; beim Darüberfahren hebt sich das Block-Symbol und dreht sich ein Stück +- Designwechsel blenden weich über statt hart umzuschalten +- Optionen → Darstellung → Animationen: „Startanimation“ und „Bewegte Oberfläche“ einzeln abschaltbar; ist in Windows „Animationen anzeigen“ aus, bleibt die Oberfläche ruhig + ## [1.1.8] - 2026-10-04 ### Behoben diff --git a/README.md b/README.md index 483ee49..a51381f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung. -Aktueller Stand: Version 1.1.8 +Aktueller Stand: Version 1.2.0 AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau @@ -62,7 +62,7 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen - Eigener Server auf Wunsch verborgen, Freundesystem komplett abschaltbar **Konten, Welten und Backups** -- Microsoft-Login mit mehreren Konten +- Microsoft-Login mit mehreren Konten – Wechsel mit einem Klick über das Konto-Menü oben rechts - Eigenen Skin im Launcher hochladen, mit Live-Vorschau anpassen (klassisch/schlank) und zurücksetzen - **Skin-Editor mit zwei Schichten**: Skins selbst pixeln – auf der Vorlage oder direkt auf der 3D-Figur, mit Spiegeln, Füllen, Pipette und Rückgängig - **Eigene Umhänge, auch HD bis 1024×512**: im Skin-Editor gestalten, im Spiel in jeder Version und mit jedem Loader sichtbar – für Spieler mit anderen Launchern gibt es den Mod „AeroMC Umhänge“ @@ -132,7 +132,7 @@ Automatische Updates bleiben getrennt: Setup erhält nur Setup-Updates, Portable Jede Instanz hat: -- Namen und optionale Gruppe +- Namen und optionale Gruppe – Untergruppen mit „/“ trennen, z. B. `Modpacks/Survival` (eingerückt, einzeln einklappbar, per Drag & Drop befüllbar) - Minecraft-Version und optionalen Mod-Loader - optionales Icon (vorgegeben, aus Minecraft extrahiert oder eigenes Bild) und Notizen - eigene Mods, Ressourcenpakete, Shader, Welten, Datenpakete, Screenshots, Aufzeichnungen und Serverliste @@ -197,6 +197,8 @@ Im Spiel (solange ein über AeroMC gestartetes Minecraft läuft): Existiert bereits eine Instanz mit demselben Namen, wird sie ersetzt. +**Große Importe:** Auch viele Gigabyte werden im Hintergrund kopiert – der Launcher bleibt bedienbar. Vorher prüft AeroMC, ob genug Speicherplatz frei ist. Oben im Import-Fenster stehen Instanz, übertragene GB, Prozent, Tempo, Restzeit und die aktuelle Datei; **Abbrechen** behält fertig übernommene Instanzen und entfernt die halb kopierte. Einzelne gesperrte Dateien werden übersprungen und gemeldet. + Beim offiziellen Minecraft Launcher werden nur benutzerdefinierte Profile angeboten (keine „Latest Release“/„Latest Snapshot“). Bei gemeinsamen Standard-Spielordnern übernimmt AeroMC spielrelevante Daten (Mods, Welten, Konfiguration), nicht den kompletten Cache. **Modrinth App:** Übernommen werden Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen. Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich weiter aktualisieren. Ein in der App verlegter Datenordner wird automatisch gefunden, und die Modrinth App darf dabei geöffnet bleiben – AeroMC liest auch ihr Schreibprotokoll mit. An den Daten der Modrinth App wird nichts verändert. @@ -337,6 +339,10 @@ Speicherort: `Dokumente\AeroMC Launcher\Backups`. ### Darstellung - Design: Klassisch Dunkel, Klassisch Hell, AeroMC, Nachtlila oder Minecraft – mit Vorschau-Kacheln, gilt nach dem Speichern in allen Fenstern - Block-Symbole räumlich (3D) +- Animationen: + - **Startanimation**: Beim Öffnen dreht sich das AeroMC-Logo, danach baut sich das Fenster von der Mitte aus auf (nicht beim Start im Infobereich) + - **Bewegte Oberfläche**: Die Skin-Figur im Hintergrund atmet und schaut dem Mauszeiger nach, Fenster wachsen aus dem angeklickten Knopf, Instanzen blenden beim Öffnen ein und heben sich beim Darüberfahren, Designs blenden weich über + - Beides einzeln abschaltbar; ist in Windows „Animationen anzeigen“ aus, bleibt die Oberfläche ruhig - Skin-Hintergrund: Quelle, Pose, Armform (automatisch vom Konto, normal oder schmal), Deckkraft, optional automatischer Wechsel zwischen mehreren Konten ### Spiel diff --git a/installer/obj/AeroMCSetup.csproj.nuget.dgspec.json b/installer/obj/AeroMCSetup.csproj.nuget.dgspec.json index a6c13f1..0f515dd 100644 --- a/installer/obj/AeroMCSetup.csproj.nuget.dgspec.json +++ b/installer/obj/AeroMCSetup.csproj.nuget.dgspec.json @@ -5,7 +5,7 @@ }, "projects": { "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": { - "version": "1.1.8", + "version": "1.2.0", "restore": { "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectName": "AeroMC-Setup", diff --git a/installer/obj/Release/net48/AeroMC-Setup.exe b/installer/obj/Release/net48/AeroMC-Setup.exe index 09a8c0e1ca7e23706774eaf75dc5bbb7dc05a7e5..dead53d7a8564ce594e4550e1b022639e349c0a0 100644 GIT binary patch delta 136 zcmZp8!Qb$Le?kY#iTelpHugkKV`gGt*!*&uaE!o`jsu_k8&^8-@DUH%yZ`%^%{-qc zi3k|!8R!`>FmN$4uo~(CIqmD%8G)E-`#N^!R}cA_7#JBq07`FP{*t+rk=2MnkHKKO W;%nx5D0}+SH_ZA_;hS%m)!6}1nJWDN delta 136 zcmZp8!Qb$Le?kX~1pn*ajXhD*m>C&3Hou%E93!x@{?1P^@j&NE(rGE#F>DQ+c|K1P z5ir!V&@*6Q;9_K8HPi!g+Sjo&0x{F}b?nTq9`Z9XFfuSOZ~#d#y?yyh=2AvhLk2wt Zi|vZ9nd_nK=}X@*>qCWazF}5p2LP+vCo%v4 diff --git a/installer/obj/project.assets.json b/installer/obj/project.assets.json index e41052e..e59c94b 100644 --- a/installer/obj/project.assets.json +++ b/installer/obj/project.assets.json @@ -418,7 +418,7 @@ "C:\\Users\\Viper\\.nuget\\packages\\": {} }, "project": { - "version": "1.1.8", + "version": "1.2.0", "restore": { "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectName": "AeroMC-Setup", diff --git a/installer/obj/project.nuget.cache b/installer/obj/project.nuget.cache index 147b28f..03cdf74 100644 --- a/installer/obj/project.nuget.cache +++ b/installer/obj/project.nuget.cache @@ -1,6 +1,6 @@ { "version": 2, - "dgSpecHash": "UguGrVny76E=", + "dgSpecHash": "IwFtzT9tvgw=", "success": true, "projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "expectedPackageFiles": [ diff --git a/package-lock.json b/package-lock.json index 1f49d12..0203629 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aeromc", - "version": "1.1.8", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aeromc", - "version": "1.1.8", + "version": "1.2.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@xhayper/discord-rpc": "^1.3.4", diff --git a/package.json b/package.json index e21db05..cdd95a0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aeromc", "productName": "AeroMC", - "version": "1.1.8", + "version": "1.2.0", "description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", "homepage": "https://m-viper.de", "launcherWebsite": "https://aeromc.viper.ipv64.net", diff --git a/wordpress-plugin/aeromc-friends/aeromc-friends.php b/wordpress-plugin/aeromc-friends/aeromc-friends.php index 9ab507e..ce2e864 100644 --- a/wordpress-plugin/aeromc-friends/aeromc-friends.php +++ b/wordpress-plugin/aeromc-friends/aeromc-friends.php @@ -2,7 +2,7 @@ /** * Plugin Name: AeroMC Freunde * Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort. - * Version: 1.5.0 + * Version: 1.5.1 * Author: M_Viper */ @@ -10,7 +10,7 @@ if (!defined('ABSPATH')) { exit; // kein direkter Aufruf der Datei } -define('AEROMC_FRIENDS_VERSION', '1.5.0'); +define('AEROMC_FRIENDS_VERSION', '1.5.1'); define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence'); define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends'); define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites'); diff --git a/wordpress-plugin/aeromc-friends/includes/routes.php b/wordpress-plugin/aeromc-friends/includes/routes.php index 21d9df0..b18bd08 100644 --- a/wordpress-plugin/aeromc-friends/includes/routes.php +++ b/wordpress-plugin/aeromc-friends/includes/routes.php @@ -1,1027 +1,1050 @@ -prepare, um SQL-Injection - * auszuschließen. - */ - -if (!defined('ABSPATH')) { - exit; -} - -// Konten gelten als "online", solange ihr letzter Herzschlag (der Launcher -// sendet alle 60s einen) nicht älter als das hier ist. -define('AEROMC_FRIENDS_ONLINE_SECONDS', 180); -// Häufigere Herzschläge als das werden ignoriert (Schutz gegen Missbrauch/ -// versehentliche Anfrageflut) - der Launcher selbst sendet ohnehin nur alle 60s. -// Ausnahme: hat sich der Server geändert, wird sofort gespeichert ("Beitreten"). -define('AEROMC_FRIENDS_HEARTBEAT_MIN_GAP', 20); -// So lange ist eine Einladung "Komm auf meinen Server" gültig. -define('AEROMC_FRIENDS_INVITE_SECONDS', 600); -// Frühestens nach so vielen Sekunden darf derselbe Freund erneut eingeladen werden. -define('AEROMC_FRIENDS_INVITE_MIN_GAP', 30); - -// Sitzungen verfallen, wenn sie so lange nicht benutzt wurden. -define('AEROMC_FRIENDS_SESSION_DAYS', 30); -// So lange ist ein Teilen-Code gültig. -define('AEROMC_FRIENDS_SHARE_DAYS', 30); -// Höchstens so viele geteilte Instanzen pro Spieler und Tag. -define('AEROMC_FRIENDS_SHARES_PER_DAY', 30); -// Größe einer geteilten Mod-Liste (JSON) in Bytes. -define('AEROMC_FRIENDS_SHARE_MAX_BYTES', 262144); -// Kurznachrichten: Länge, Aufbewahrung, höchstens so viele pro Minute. -define('AEROMC_FRIENDS_MESSAGE_MAX_CHARS', 200); -define('AEROMC_FRIENDS_MESSAGE_DAYS', 7); -define('AEROMC_FRIENDS_MESSAGES_PER_MINUTE', 10); -// Bilder in Nachrichten (geteilte Screenshots): Höchstgröße und Ordner unter wp-content/uploads -define('AEROMC_FRIENDS_IMAGE_MAX_BYTES', 2500000); -define('AEROMC_FRIENDS_IMAGE_DIR', 'aeromc-bilder'); - -/** Speichert ein Bild (JPEG/PNG, base64) unter zufälligem Namen; gibt die URL zurück oder WP_Error. */ -function aeromc_friends_save_image($base64) { - $daten = base64_decode(preg_replace('/^data:image\/\w+;base64,/', '', (string) $base64), true); - if ($daten === false || strlen($daten) < 100) { - return new WP_Error('aeromc_invalid', 'Ungültiges Bild.', ['status' => 400]); - } - if (strlen($daten) > AEROMC_FRIENDS_IMAGE_MAX_BYTES) { - return new WP_Error('aeromc_too_big', 'Das Bild ist zu groß.', ['status' => 413]); - } - // nur echte JPEG- oder PNG-Dateien annehmen (Dateikopf prüfen, nicht der Angabe trauen) - if (strncmp($daten, "\xFF\xD8\xFF", 3) === 0) { - $endung = 'jpg'; - } elseif (strncmp($daten, "\x89PNG\r\n\x1A\n", 8) === 0) { - $endung = 'png'; - } else { - return new WP_Error('aeromc_invalid', 'Nur JPEG- oder PNG-Bilder.', ['status' => 400]); - } - $uploads = wp_upload_dir(); - $ordner = trailingslashit($uploads['basedir']) . AEROMC_FRIENDS_IMAGE_DIR; - if (!wp_mkdir_p($ordner)) { - return new WP_Error('aeromc_error', 'Bild konnte nicht gespeichert werden.', ['status' => 500]); - } - // Verzeichnisliste verhindern - if (!file_exists($ordner . '/index.html')) { - file_put_contents($ordner . '/index.html', ''); - } - $name = bin2hex(random_bytes(16)) . '.' . $endung; - if (file_put_contents($ordner . '/' . $name, $daten) === false) { - return new WP_Error('aeromc_error', 'Bild konnte nicht gespeichert werden.', ['status' => 500]); - } - return trailingslashit($uploads['baseurl']) . AEROMC_FRIENDS_IMAGE_DIR . '/' . $name; -} - -/** Bilddatei zu einer gespeicherten URL löschen (nur aus unserem Ordner). */ -function aeromc_friends_delete_image($url) { - if (!$url) { - return; - } - $name = basename((string) $url); - if (!preg_match('/^[a-f0-9]{32}\.(jpg|png)$/', $name)) { - return; - } - $uploads = wp_upload_dir(); - $datei = trailingslashit($uploads['basedir']) . AEROMC_FRIENDS_IMAGE_DIR . '/' . $name; - if (file_exists($datei)) { - @unlink($datei); - } -} -// Fehlerberichte: Größe des mitgeschickten Berichts, höchstens so viele pro Stunde und Absender. -define('AEROMC_FRIENDS_REPORT_MAX_BYTES', 200000); -define('AEROMC_FRIENDS_REPORTS_PER_HOUR', 5); - -function aeromc_friends_messages_table() { - global $wpdb; - return $wpdb->prefix . AEROMC_FRIENDS_MESSAGES_TABLE; -} - -function aeromc_friends_reports_table() { - global $wpdb; - return $wpdb->prefix . AEROMC_FRIENDS_REPORTS_TABLE; -} - -function aeromc_friends_sessions_table() { - global $wpdb; - return $wpdb->prefix . AEROMC_FRIENDS_SESSIONS_TABLE; -} - -function aeromc_friends_shares_table() { - global $wpdb; - return $wpdb->prefix . AEROMC_FRIENDS_SHARES_TABLE; -} - -/** - * Prüft, ob die Anfrage wirklich von diesem Spieler kommt. - * Gibt die Sitzung (Objekt mit username) zurück, null im Übergangsmodus - * ohne Token, oder WP_Error (401), wenn die Anmeldung fehlt oder ungültig ist. - */ -function aeromc_friends_check_auth($request, $uuid) { - global $wpdb; - $table = aeromc_friends_sessions_table(); - $token = (string) $request->get_header('x-aeromc-token'); - if ($token !== '') { - $hash = hash('sha256', $token); - $grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_SESSION_DAYS * 86400); - $sitzung = $wpdb->get_row($wpdb->prepare( - "SELECT uuid, username, last_used FROM $table WHERE token_hash = %s AND last_used >= %s", $hash, $grenze - )); - if (!$sitzung || $sitzung->uuid !== $uuid) { - return new WP_Error('aeromc_auth', 'Anmeldung ungültig oder abgelaufen.', ['status' => 401]); - } - // "zuletzt benutzt" höchstens stündlich schreiben - if (time() - strtotime($sitzung->last_used) > 3600) { - $wpdb->update($table, ['last_used' => current_time('mysql', true)], ['token_hash' => $hash]); - } - return $sitzung; - } - // ohne Token: nur im Übergangsmodus und nur, solange dieser Spieler sich - // noch nie angemeldet hat - sonst könnte man sich weiter als er ausgeben - $schon_angemeldet = $wpdb->get_var($wpdb->prepare("SELECT 1 FROM $table WHERE uuid = %s LIMIT 1", $uuid)); - if (AEROMC_FRIENDS_REQUIRE_AUTH || $schon_angemeldet) { - return new WP_Error('aeromc_auth', 'Anmeldung erforderlich.', ['status' => 401]); - } - return null; -} - -function aeromc_friends_invites_table() { - global $wpdb; - return $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE; -} - -/** Server-Adresse (Host oder IP, optional :Port) - nur harmlose Zeichen. */ -function aeromc_friends_valid_server($server) { - return is_string($server) && preg_match('/^[A-Za-z0-9.\-_:\[\]]{1,128}$/', $server); -} - -/** Minecraft-Version wie "1.21.1", "24w14a" oder "1.20.1-pre2". */ -function aeromc_friends_clean_version($version) { - $version = (string) $version; - return preg_match('/^[A-Za-z0-9.\-_ ]{1,32}$/', $version) ? $version : ''; -} - -/** Sind die beiden befreundet (Anfrage angenommen)? */ -function aeromc_friends_are_friends($a, $b) { - global $wpdb; - $friends_table = aeromc_friends_friends_table(); - return (bool) $wpdb->get_var($wpdb->prepare( - "SELECT id FROM $friends_table WHERE ((uuid_a = %s AND uuid_b = %s) OR (uuid_a = %s AND uuid_b = %s)) AND status = 'accepted'", - $a, $b, $b, $a - )); -} - -function aeromc_friends_presence_table() { - global $wpdb; - return $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE; -} - -function aeromc_friends_friends_table() { - global $wpdb; - return $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE; -} - -/** Minecraft-UUIDs kommen mal mit, mal ohne Bindestriche an - einheitlich - * ohne Bindestriche, klein geschrieben speichern/vergleichen. Gibt false - * zurück, wenn es keine gültige UUID (32 Hex-Zeichen) ist. */ -function aeromc_friends_normalize_uuid($uuid) { - $stripped = strtolower(str_replace('-', '', (string) $uuid)); - if (!preg_match('/^[a-f0-9]{32}$/', $stripped)) { - return false; - } - return $stripped; -} - -/** Minecraft-Namensregeln: 1-16 Zeichen, Buchstaben/Ziffern/Unterstrich. */ -function aeromc_friends_valid_username($name) { - return is_string($name) && preg_match('/^[A-Za-z0-9_]{1,16}$/', $name); -} - -add_action('rest_api_init', function () { - register_rest_route('aeromc/v1', '/auth/challenge', [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_auth_challenge', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/auth/verify', [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_auth_verify', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/share', [ - [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_share_create', - 'permission_callback' => '__return_true', - ], - [ - 'methods' => 'GET', - 'callback' => 'aeromc_friends_route_share_get', - 'permission_callback' => '__return_true', - ], - ]); - register_rest_route('aeromc/v1', '/friends/message', [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_message_send', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends/messages', [ - 'methods' => 'GET', - 'callback' => 'aeromc_friends_route_messages', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends/messages/unread', [ - 'methods' => 'GET', - 'callback' => 'aeromc_friends_route_messages_unread', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends/messages/read', [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_messages_read', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/report', [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_report', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/presence', [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_presence', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends', [ - 'methods' => 'GET', - 'callback' => 'aeromc_friends_route_list', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends/requests', [ - 'methods' => 'GET', - 'callback' => 'aeromc_friends_route_requests', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends/sent', [ - 'methods' => 'GET', - 'callback' => 'aeromc_friends_route_sent', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends/request', [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_request', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends/respond', [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_respond', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends/cancel', [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_cancel', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends/remove', [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_remove', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends/invite', [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_invite', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends/invites', [ - 'methods' => 'GET', - 'callback' => 'aeromc_friends_route_invites', - 'permission_callback' => '__return_true', - ]); - register_rest_route('aeromc/v1', '/friends/invites/dismiss', [ - 'methods' => 'POST', - 'callback' => 'aeromc_friends_route_invite_dismiss', - 'permission_callback' => '__return_true', - ]); -}); - -/** POST /presence {uuid, username, instance, server, version} */ -function aeromc_friends_route_presence($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - $username = $request->get_param('username'); - if (!$uuid || !aeromc_friends_valid_username($username)) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid/username.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - if ($sitzung) { - $username = $sitzung->username; // von Mojang bestätigt - } - $instance = sanitize_text_field((string) $request->get_param('instance')); - $server = sanitize_text_field((string) $request->get_param('server')); - $version = aeromc_friends_clean_version($request->get_param('version')); - $table = aeromc_friends_presence_table(); - - $letzte = $wpdb->get_row($wpdb->prepare( - "SELECT last_seen, server_label FROM $table WHERE uuid = %s", $uuid - )); - $server_gleich = $letzte && (string) $letzte->server_label === (string) $server; - if ($letzte && $server_gleich && (time() - strtotime($letzte->last_seen)) < AEROMC_FRIENDS_HEARTBEAT_MIN_GAP) { - return ['ok' => true]; // zu häufig - stillschweigend ignoriert - } - - // current_time('mysql', true) statt current_time('mysql'): das zweite - // Argument erzwingt GMT/UTC. Ohne das liefert current_time() die an die - // WordPress-Zeitzone angepasste ORTSZEIT (Einstellungen > Allgemein) - - // das online/offline-Fenster unten vergleicht last_seen aber per - // strtotime()/time() als UTC, wodurch ein Freund bei abweichender - // Zeitzone praktisch nie als offline erkannt worden wäre. - $jetzt_utc = current_time('mysql', true); - $wpdb->query($wpdb->prepare( - "INSERT INTO $table (uuid, username, instance_name, server_label, mc_version, last_seen) - VALUES (%s, %s, %s, %s, %s, %s) - ON DUPLICATE KEY UPDATE username = %s, instance_name = %s, server_label = %s, mc_version = %s, last_seen = %s", - $uuid, $username, $instance ?: null, $server ?: null, $version ?: null, $jetzt_utc, - $username, $instance ?: null, $server ?: null, $version ?: null, $jetzt_utc - )); - return ['ok' => true]; -} - -/** GET /friends?uuid= - akzeptierte Freunde inkl. Online-Status */ -function aeromc_friends_route_list($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - if (!$uuid) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $friends_table = aeromc_friends_friends_table(); - $presence_table = aeromc_friends_presence_table(); - - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT IF(f.uuid_a = %s, f.uuid_b, f.uuid_a) AS other_uuid - FROM $friends_table f - WHERE (f.uuid_a = %s OR f.uuid_b = %s) AND f.status = 'accepted'", - $uuid, $uuid, $uuid - )); - - $ergebnis = []; - foreach ($rows as $row) { - $p = $wpdb->get_row($wpdb->prepare( - "SELECT username, instance_name, server_label, mc_version, last_seen FROM $presence_table WHERE uuid = %s", - $row->other_uuid - )); - $online = $p && (time() - strtotime($p->last_seen)) < AEROMC_FRIENDS_ONLINE_SECONDS; - $ergebnis[] = [ - 'uuid' => $row->other_uuid, - 'username' => $p ? $p->username : null, - 'online' => (bool) $online, - 'instance' => $online ? $p->instance_name : null, - 'server' => $online ? $p->server_label : null, - 'version' => $online ? $p->mc_version : null, - ]; - } - return $ergebnis; -} - -/** GET /friends/requests?uuid= - offene, an mich gerichtete Anfragen */ -function aeromc_friends_route_requests($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - if (!$uuid) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $friends_table = aeromc_friends_friends_table(); - $presence_table = aeromc_friends_presence_table(); - - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT f.uuid_a AS from_uuid, p.username AS from_username - FROM $friends_table f - LEFT JOIN $presence_table p ON p.uuid = f.uuid_a - WHERE f.uuid_b = %s AND f.status = 'pending'", - $uuid - )); - return array_map(function ($r) { - return ['uuid' => $r->from_uuid, 'username' => $r->from_username]; - }, $rows); -} - -/** GET /friends/sent?uuid= - offene, von mir SELBST gesendete Anfragen */ -function aeromc_friends_route_sent($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - if (!$uuid) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $friends_table = aeromc_friends_friends_table(); - $presence_table = aeromc_friends_presence_table(); - - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT f.uuid_b AS to_uuid, p.username AS to_username - FROM $friends_table f - LEFT JOIN $presence_table p ON p.uuid = f.uuid_b - WHERE f.uuid_a = %s AND f.status = 'pending'", - $uuid - )); - return array_map(function ($r) { - return ['uuid' => $r->to_uuid, 'username' => $r->to_username]; - }, $rows); -} - -/** POST /friends/request {uuid, username, targetUsername} */ -function aeromc_friends_route_request($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - $targetUsername = $request->get_param('targetUsername'); - if (!$uuid || !aeromc_friends_valid_username($targetUsername)) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid/targetUsername.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $presence_table = aeromc_friends_presence_table(); - $friends_table = aeromc_friends_friends_table(); - - $ziel = $wpdb->get_row($wpdb->prepare( - "SELECT uuid FROM $presence_table WHERE username = %s", $targetUsername - )); - if (!$ziel) { - return new WP_Error('aeromc_not_found', 'Dieser Name wurde noch nie im Launcher gesehen.', ['status' => 404]); - } - $ziel_uuid = $ziel->uuid; - if ($ziel_uuid === $uuid) { - return new WP_Error('aeromc_self', 'Du kannst dich nicht selbst hinzufügen.', ['status' => 400]); - } - - $vorhanden = $wpdb->get_var($wpdb->prepare( - "SELECT id FROM $friends_table WHERE (uuid_a = %s AND uuid_b = %s) OR (uuid_a = %s AND uuid_b = %s)", - $uuid, $ziel_uuid, $ziel_uuid, $uuid - )); - if ($vorhanden) { - return new WP_Error('aeromc_exists', 'Es gibt schon eine Anfrage oder Freundschaft.', ['status' => 409]); - } - - $wpdb->insert($friends_table, [ - 'uuid_a' => $uuid, - 'uuid_b' => $ziel_uuid, - 'status' => 'pending', - 'created_at' => current_time('mysql'), - ]); - return ['ok' => true]; -} - -/** POST /friends/respond {uuid, otherUuid, accept} */ -function aeromc_friends_route_respond($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - $other = aeromc_friends_normalize_uuid($request->get_param('otherUuid')); - $accept = (bool) $request->get_param('accept'); - if (!$uuid || !$other) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid/otherUuid.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $friends_table = aeromc_friends_friends_table(); - - if ($accept) { - $wpdb->query($wpdb->prepare( - "UPDATE $friends_table SET status = 'accepted' WHERE uuid_a = %s AND uuid_b = %s AND status = 'pending'", - $other, $uuid - )); - } else { - $wpdb->query($wpdb->prepare( - "DELETE FROM $friends_table WHERE uuid_a = %s AND uuid_b = %s AND status = 'pending'", - $other, $uuid - )); - } - return ['ok' => true]; -} - -/** POST /friends/cancel {uuid, otherUuid} - eine SELBST gesendete Anfrage zurückziehen */ -function aeromc_friends_route_cancel($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - $other = aeromc_friends_normalize_uuid($request->get_param('otherUuid')); - if (!$uuid || !$other) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid/otherUuid.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $friends_table = aeromc_friends_friends_table(); - $wpdb->query($wpdb->prepare( - "DELETE FROM $friends_table WHERE uuid_a = %s AND uuid_b = %s AND status = 'pending'", - $uuid, $other - )); - return ['ok' => true]; -} - -/** POST /friends/remove {uuid, otherUuid} */ -function aeromc_friends_route_remove($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - $other = aeromc_friends_normalize_uuid($request->get_param('otherUuid')); - if (!$uuid || !$other) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid/otherUuid.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $friends_table = aeromc_friends_friends_table(); - $wpdb->query($wpdb->prepare( - "DELETE FROM $friends_table WHERE ((uuid_a = %s AND uuid_b = %s) OR (uuid_a = %s AND uuid_b = %s)) AND status = 'accepted'", - $uuid, $other, $other, $uuid - )); - return ['ok' => true]; -} - -/** POST /friends/invite {uuid, targetUuid, server, version} - "Komm auf meinen Server" */ -function aeromc_friends_route_invite($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - $target = aeromc_friends_normalize_uuid($request->get_param('targetUuid')); - $server = (string) $request->get_param('server'); - $version = aeromc_friends_clean_version($request->get_param('version')); - if (!$uuid || !$target || !aeromc_friends_valid_server($server)) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid/targetUuid/server.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - if (!aeromc_friends_are_friends($uuid, $target)) { - return new WP_Error('aeromc_not_friends', 'Ihr seid nicht befreundet.', ['status' => 403]); - } - $table = aeromc_friends_invites_table(); - - $letzte = $wpdb->get_var($wpdb->prepare( - "SELECT created_at FROM $table WHERE from_uuid = %s AND to_uuid = %s ORDER BY id DESC LIMIT 1", - $uuid, $target - )); - if ($letzte && (time() - strtotime($letzte)) < AEROMC_FRIENDS_INVITE_MIN_GAP) { - return new WP_Error('aeromc_too_fast', 'Du hast diesen Freund gerade erst eingeladen.', ['status' => 429]); - } - - // ältere Einladung an denselben Freund ersetzen - $wpdb->query($wpdb->prepare( - "DELETE FROM $table WHERE from_uuid = %s AND to_uuid = %s", $uuid, $target - )); - $wpdb->insert($table, [ - 'from_uuid' => $uuid, - 'to_uuid' => $target, - 'server' => $server, - 'mc_version' => $version ?: null, - 'created_at' => current_time('mysql', true), - ]); - return ['ok' => true]; -} - -/** GET /friends/invites?uuid= - an mich gerichtete, noch gültige Einladungen */ -function aeromc_friends_route_invites($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - if (!$uuid) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $table = aeromc_friends_invites_table(); - $presence_table = aeromc_friends_presence_table(); - - // abgelaufene Einladungen (aller Spieler) wegräumen - $grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_INVITE_SECONDS); - $wpdb->query($wpdb->prepare("DELETE FROM $table WHERE created_at < %s", $grenze)); - - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT i.id, i.from_uuid, p.username AS from_username, i.server, i.mc_version, i.created_at - FROM $table i - LEFT JOIN $presence_table p ON p.uuid = i.from_uuid - WHERE i.to_uuid = %s - ORDER BY i.id DESC", - $uuid - )); - return array_map(function ($r) { - return [ - 'id' => (int) $r->id, - 'fromUuid' => $r->from_uuid, - 'fromUsername' => $r->from_username, - 'server' => $r->server, - 'version' => $r->mc_version, - 'createdAt' => $r->created_at, - ]; - }, $rows); -} - -/** POST /friends/invites/dismiss {uuid, id} - Einladung erledigt/abgelehnt */ -function aeromc_friends_route_invite_dismiss($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - $id = (int) $request->get_param('id'); - if (!$uuid || $id <= 0) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid/id.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $table = aeromc_friends_invites_table(); - $wpdb->query($wpdb->prepare("DELETE FROM $table WHERE id = %d AND to_uuid = %s", $id, $uuid)); - return ['ok' => true]; -} - -// --------------------------------------------------------------------------- -// Anmeldung über Mojangs Sitzungsprüfung -// --------------------------------------------------------------------------- - -/** POST /auth/challenge {uuid, username} - Aufgabe für die Anmeldung bei Mojang */ -function aeromc_friends_route_auth_challenge($request) { - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - $username = $request->get_param('username'); - if (!$uuid || !aeromc_friends_valid_username($username)) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid/username.', ['status' => 400]); - } - $server_id = bin2hex(random_bytes(20)); - set_transient('aeromc_ch_' . $uuid, ['sid' => $server_id, 'name' => $username], 120); - return ['serverId' => $server_id]; -} - -/** POST /auth/verify {uuid} - fragt bei Mojang nach und gibt ein Sitzungs-Token aus */ -function aeromc_friends_route_auth_verify($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - if (!$uuid) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); - } - $aufgabe = get_transient('aeromc_ch_' . $uuid); - delete_transient('aeromc_ch_' . $uuid); // nur einmal verwendbar - if (!is_array($aufgabe) || empty($aufgabe['sid'])) { - return new WP_Error('aeromc_auth', 'Keine oder abgelaufene Anmelde-Aufgabe.', ['status' => 403]); - } - $url = 'https://sessionserver.mojang.com/session/minecraft/hasJoined?username=' - . rawurlencode($aufgabe['name']) . '&serverId=' . rawurlencode($aufgabe['sid']); - $antwort = wp_remote_get($url, ['timeout' => 10]); - if (is_wp_error($antwort) || wp_remote_retrieve_response_code($antwort) !== 200) { - return new WP_Error('aeromc_auth', 'Mojang hat die Anmeldung nicht bestätigt.', ['status' => 403]); - } - $profil = json_decode(wp_remote_retrieve_body($antwort), true); - if (!is_array($profil) || aeromc_friends_normalize_uuid($profil['id'] ?? '') !== $uuid - || !aeromc_friends_valid_username($profil['name'] ?? '')) { - return new WP_Error('aeromc_auth', 'Mojang hat einen anderen Spieler gemeldet.', ['status' => 403]); - } - - $table = aeromc_friends_sessions_table(); - // abgelaufene Sitzungen (aller Spieler) wegräumen - $grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_SESSION_DAYS * 86400); - $wpdb->query($wpdb->prepare("DELETE FROM $table WHERE last_used < %s", $grenze)); - - $token = bin2hex(random_bytes(32)); - $jetzt = current_time('mysql', true); - $wpdb->insert($table, [ - 'token_hash' => hash('sha256', $token), - 'uuid' => $uuid, - 'username' => $profil['name'], - 'created_at' => $jetzt, - 'last_used' => $jetzt, - ]); - return ['token' => $token, 'username' => $profil['name']]; -} - -// --------------------------------------------------------------------------- -// Instanzen per Code teilen -// --------------------------------------------------------------------------- - -/** Code ohne verwechselbare Zeichen (kein 0/O, 1/I/L), Form XXXX-XXXX. */ -function aeromc_friends_new_share_code() { - $zeichen = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; - $code = ''; - for ($i = 0; $i < 8; $i++) { - $code .= $zeichen[random_int(0, strlen($zeichen) - 1)]; - if ($i === 3) { - $code .= '-'; - } - } - return $code; -} - -/** Eingegebenen Code vereinheitlichen ("ab12 cd34" -> "AB12-CD34"), false wenn ungültig. */ -function aeromc_friends_normalize_share_code($code) { - $roh = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string) $code)); - if (strlen($roh) !== 8) { - return false; - } - return substr($roh, 0, 4) . '-' . substr($roh, 4); -} - -/** POST /share {uuid, manifest} - Mod-Liste einer Instanz hinterlegen, Code zurück */ -function aeromc_friends_route_share_create($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - if (!$uuid) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $manifest = $request->get_param('manifest'); - if (!is_array($manifest) || empty($manifest['minecraft']) || !isset($manifest['inhalte'])) { - return new WP_Error('aeromc_invalid', 'Ungültige Instanz-Beschreibung.', ['status' => 400]); - } - $json = wp_json_encode($manifest); - if (!$json || strlen($json) > AEROMC_FRIENDS_SHARE_MAX_BYTES) { - return new WP_Error('aeromc_too_big', 'Die Instanz ist zu groß zum Teilen.', ['status' => 413]); - } - $table = aeromc_friends_shares_table(); - - $seit = gmdate('Y-m-d H:i:s', time() - 86400); - $heute = (int) $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM $table WHERE uuid = %s AND created_at >= %s", $uuid, $seit - )); - if ($heute >= AEROMC_FRIENDS_SHARES_PER_DAY) { - return new WP_Error('aeromc_too_fast', 'Heute wurden schon sehr viele Instanzen geteilt – bitte morgen wieder.', ['status' => 429]); - } - - // abgelaufene Codes (aller Spieler) wegräumen - $grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_SHARE_DAYS * 86400); - $wpdb->query($wpdb->prepare("DELETE FROM $table WHERE created_at < %s", $grenze)); - - for ($versuch = 0; $versuch < 5; $versuch++) { - $code = aeromc_friends_new_share_code(); - $ok = $wpdb->insert($table, [ - 'code' => $code, - 'uuid' => $uuid, - 'manifest' => $json, - 'created_at' => current_time('mysql', true), - ]); - if ($ok) { - return ['code' => $code, 'days' => AEROMC_FRIENDS_SHARE_DAYS]; - } - } - return new WP_Error('aeromc_error', 'Es konnte kein Code erzeugt werden.', ['status' => 500]); -} - -/** GET /share?code= - geteilte Instanz abrufen (ohne Anmeldung: wer den Code hat, darf sie sehen) */ -function aeromc_friends_route_share_get($request) { - global $wpdb; - $code = aeromc_friends_normalize_share_code($request->get_param('code')); - if (!$code) { - return new WP_Error('aeromc_invalid', 'Ungültiger Code.', ['status' => 400]); - } - $table = aeromc_friends_shares_table(); - $presence_table = aeromc_friends_presence_table(); - $grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_SHARE_DAYS * 86400); - $row = $wpdb->get_row($wpdb->prepare( - "SELECT s.code, s.uuid, s.manifest, s.created_at, p.username - FROM $table s LEFT JOIN $presence_table p ON p.uuid = s.uuid - WHERE s.code = %s AND s.created_at >= %s", - $code, $grenze - )); - if (!$row) { - return new WP_Error('aeromc_not_found', 'Diesen Code gibt es nicht oder er ist abgelaufen.', ['status' => 404]); - } - return [ - 'code' => $row->code, - 'fromUuid' => $row->uuid, - 'fromUsername' => $row->username, - 'createdAt' => $row->created_at, - 'manifest' => json_decode($row->manifest, true), - ]; -} - -// --------------------------------------------------------------------------- -// Kurznachrichten zwischen Freunden -// --------------------------------------------------------------------------- - -/** POST /friends/message {uuid, targetUuid, text} */ -function aeromc_friends_route_message_send($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - $target = aeromc_friends_normalize_uuid($request->get_param('targetUuid')); - if (!$uuid || !$target) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid/targetUuid.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $text = trim(sanitize_text_field((string) $request->get_param('text'))); - $bild = $request->get_param('image'); - if ($text === '' && !$bild) { - return new WP_Error('aeromc_invalid', 'Leere Nachricht.', ['status' => 400]); - } - if (mb_strlen($text) > AEROMC_FRIENDS_MESSAGE_MAX_CHARS) { - $text = mb_substr($text, 0, AEROMC_FRIENDS_MESSAGE_MAX_CHARS); - } - if (!aeromc_friends_are_friends($uuid, $target)) { - return new WP_Error('aeromc_not_friends', 'Ihr seid nicht befreundet.', ['status' => 403]); - } - $table = aeromc_friends_messages_table(); - - $seit = gmdate('Y-m-d H:i:s', time() - 60); - $letzte_minute = (int) $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM $table WHERE from_uuid = %s AND created_at >= %s", $uuid, $seit - )); - if ($letzte_minute >= AEROMC_FRIENDS_MESSAGES_PER_MINUTE) { - return new WP_Error('aeromc_too_fast', 'Zu viele Nachrichten – bitte kurz warten.', ['status' => 429]); - } - - // alte Nachrichten (aller Spieler) samt ihrer Bilder wegräumen - $grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_MESSAGE_DAYS * 86400); - $alte_bilder = $wpdb->get_col($wpdb->prepare( - "SELECT image_url FROM $table WHERE created_at < %s AND image_url IS NOT NULL", $grenze - )); - foreach ($alte_bilder as $alt) { - aeromc_friends_delete_image($alt); - } - $wpdb->query($wpdb->prepare("DELETE FROM $table WHERE created_at < %s", $grenze)); - - $bild_url = null; - if ($bild) { - $bild_url = aeromc_friends_save_image($bild); - if (is_wp_error($bild_url)) { - return $bild_url; - } - } - - $wpdb->insert($table, [ - 'from_uuid' => $uuid, - 'to_uuid' => $target, - 'text' => $text, - 'image_url' => $bild_url, - 'created_at' => current_time('mysql', true), - ]); - return ['ok' => true, 'id' => (int) $wpdb->insert_id]; -} - -/** GET /friends/messages?uuid=&with= - Unterhaltung mit einem Freund (älteste zuerst, höchstens 50) */ -function aeromc_friends_route_messages($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - $other = aeromc_friends_normalize_uuid($request->get_param('with')); - if (!$uuid || !$other) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid/with.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $table = aeromc_friends_messages_table(); - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT id, from_uuid, text, image_url, created_at, read_at FROM $table - WHERE (from_uuid = %s AND to_uuid = %s) OR (from_uuid = %s AND to_uuid = %s) - ORDER BY id DESC LIMIT 50", - $uuid, $other, $other, $uuid - )); - return array_reverse(array_map(function ($r) { - return [ - 'id' => (int) $r->id, - 'fromUuid' => $r->from_uuid, - 'text' => $r->text, - 'imageUrl' => $r->image_url, - 'createdAt' => $r->created_at, - 'read' => $r->read_at !== null, - ]; - }, $rows)); -} - -/** GET /friends/messages/unread?uuid= - ungelesene Nachrichten an mich */ -function aeromc_friends_route_messages_unread($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - if (!$uuid) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $table = aeromc_friends_messages_table(); - $presence_table = aeromc_friends_presence_table(); - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT m.id, m.from_uuid, p.username AS from_username, m.text, m.image_url, m.created_at - FROM $table m LEFT JOIN $presence_table p ON p.uuid = m.from_uuid - WHERE m.to_uuid = %s AND m.read_at IS NULL - ORDER BY m.id ASC LIMIT 100", - $uuid - )); - return array_map(function ($r) { - return [ - 'id' => (int) $r->id, - 'fromUuid' => $r->from_uuid, - 'fromUsername' => $r->from_username, - 'text' => $r->text, - 'imageUrl' => $r->image_url, - 'createdAt' => $r->created_at, - ]; - }, $rows); -} - -/** POST /friends/messages/read {uuid, fromUuid} - Nachrichten eines Freundes als gelesen markieren */ -function aeromc_friends_route_messages_read($request) { - global $wpdb; - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - $from = aeromc_friends_normalize_uuid($request->get_param('fromUuid')); - if (!$uuid || !$from) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid/fromUuid.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $table = aeromc_friends_messages_table(); - $wpdb->query($wpdb->prepare( - "UPDATE $table SET read_at = %s WHERE to_uuid = %s AND from_uuid = %s AND read_at IS NULL", - current_time('mysql', true), $uuid, $from - )); - return ['ok' => true]; -} - -// --------------------------------------------------------------------------- -// Fehlerberichte aus dem Absturz-Assistenten -// --------------------------------------------------------------------------- - -/** Kurzer Text: Steuerzeichen raus, auf Länge kürzen. */ -function aeromc_friends_kurz($wert, $max) { - $text = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', (string) $wert)); - return mb_substr($text, 0, $max); -} - -/** - * POST /report {launcherVersion, mcVersion, loader, loaderVersion, system, funde[], mods[], bericht, kommentar, uuid?} - * Ohne Anmeldung möglich (der Nutzer kann anonym senden); mit uuid wird die - * Anmeldung geprüft, damit niemand Berichte unter fremdem Namen einreicht. - */ -function aeromc_friends_route_report($request) { - global $wpdb; - // Schutz gegen Fluten: pro Absender-Adresse begrenzt - $ip = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : ''; - $schluessel = 'aeromc_rep_' . md5($ip); - $anzahl = (int) get_transient($schluessel); - if ($anzahl >= AEROMC_FRIENDS_REPORTS_PER_HOUR) { - return new WP_Error('aeromc_too_fast', 'Zu viele Berichte in kurzer Zeit – bitte später noch einmal.', ['status' => 429]); - } - - $uuid = null; - $username = null; - if ($request->get_param('uuid')) { - $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); - if (!$uuid) { - return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); - } - $sitzung = aeromc_friends_check_auth($request, $uuid); - if (is_wp_error($sitzung)) { - return $sitzung; - } - $username = $sitzung ? $sitzung->username : aeromc_friends_kurz($request->get_param('username'), 32); - } - - $bericht = (string) $request->get_param('bericht'); - if (strlen($bericht) > AEROMC_FRIENDS_REPORT_MAX_BYTES) { - $bericht = substr($bericht, -AEROMC_FRIENDS_REPORT_MAX_BYTES); - } - $funde = $request->get_param('funde'); - $mods = $request->get_param('mods'); - - $wpdb->insert(aeromc_friends_reports_table(), [ - 'created_at' => current_time('mysql', true), - 'uuid' => $uuid, - 'username' => $username ?: null, - 'launcher_version' => aeromc_friends_kurz($request->get_param('launcherVersion'), 32), - 'mc_version' => aeromc_friends_kurz($request->get_param('mcVersion'), 32), - 'loader' => aeromc_friends_kurz($request->get_param('loader'), 32), - 'loader_version' => aeromc_friends_kurz($request->get_param('loaderVersion'), 64), - 'system' => aeromc_friends_kurz($request->get_param('system'), 128), - 'funde' => wp_json_encode(is_array($funde) ? array_slice($funde, 0, 20) : []), - 'mods' => wp_json_encode(is_array($mods) ? array_slice($mods, 0, 500) : []), - 'bericht' => $bericht, - 'kommentar' => mb_substr(sanitize_textarea_field((string) $request->get_param('kommentar')), 0, 2000), - 'status' => 'neu', - ]); - set_transient($schluessel, $anzahl + 1, HOUR_IN_SECONDS); - return ['ok' => true, 'id' => (int) $wpdb->insert_id]; -} +prepare, um SQL-Injection + * auszuschließen. + */ + +if (!defined('ABSPATH')) { + exit; +} + +// Konten gelten als "online", solange ihr letzter Herzschlag (der Launcher +// sendet alle 60s einen) nicht älter als das hier ist. +define('AEROMC_FRIENDS_ONLINE_SECONDS', 180); +// Häufigere Herzschläge als das werden ignoriert (Schutz gegen Missbrauch/ +// versehentliche Anfrageflut) - der Launcher selbst sendet ohnehin nur alle 60s. +// Ausnahme: hat sich der Server geändert, wird sofort gespeichert ("Beitreten"). +define('AEROMC_FRIENDS_HEARTBEAT_MIN_GAP', 20); +// So lange ist eine Einladung "Komm auf meinen Server" gültig. +define('AEROMC_FRIENDS_INVITE_SECONDS', 600); +// Frühestens nach so vielen Sekunden darf derselbe Freund erneut eingeladen werden. +define('AEROMC_FRIENDS_INVITE_MIN_GAP', 30); + +// Sitzungen verfallen, wenn sie so lange nicht benutzt wurden. +define('AEROMC_FRIENDS_SESSION_DAYS', 30); +// So lange ist ein Teilen-Code gültig. +define('AEROMC_FRIENDS_SHARE_DAYS', 30); +// Höchstens so viele geteilte Instanzen pro Spieler und Tag. +define('AEROMC_FRIENDS_SHARES_PER_DAY', 30); +// Größe einer geteilten Mod-Liste (JSON) in Bytes. +define('AEROMC_FRIENDS_SHARE_MAX_BYTES', 262144); +// Kurznachrichten: Länge, Aufbewahrung, höchstens so viele pro Minute. +define('AEROMC_FRIENDS_MESSAGE_MAX_CHARS', 200); +define('AEROMC_FRIENDS_MESSAGE_DAYS', 7); +define('AEROMC_FRIENDS_MESSAGES_PER_MINUTE', 10); +// Bilder in Nachrichten (geteilte Screenshots): Höchstgröße und Ordner unter wp-content/uploads +define('AEROMC_FRIENDS_IMAGE_MAX_BYTES', 2500000); +define('AEROMC_FRIENDS_IMAGE_DIR', 'aeromc-bilder'); + +/** Speichert ein Bild (JPEG/PNG, base64) unter zufälligem Namen; gibt die URL zurück oder WP_Error. */ +function aeromc_friends_save_image($base64) { + $daten = base64_decode(preg_replace('/^data:image\/\w+;base64,/', '', (string) $base64), true); + if ($daten === false || strlen($daten) < 100) { + return new WP_Error('aeromc_invalid', 'Ungültiges Bild.', ['status' => 400]); + } + if (strlen($daten) > AEROMC_FRIENDS_IMAGE_MAX_BYTES) { + return new WP_Error('aeromc_too_big', 'Das Bild ist zu groß.', ['status' => 413]); + } + // nur echte JPEG- oder PNG-Dateien annehmen (Dateikopf prüfen, nicht der Angabe trauen) + if (strncmp($daten, "\xFF\xD8\xFF", 3) === 0) { + $endung = 'jpg'; + } elseif (strncmp($daten, "\x89PNG\r\n\x1A\n", 8) === 0) { + $endung = 'png'; + } else { + return new WP_Error('aeromc_invalid', 'Nur JPEG- oder PNG-Bilder.', ['status' => 400]); + } + $uploads = wp_upload_dir(); + $ordner = trailingslashit($uploads['basedir']) . AEROMC_FRIENDS_IMAGE_DIR; + if (!wp_mkdir_p($ordner)) { + return new WP_Error('aeromc_error', 'Bild konnte nicht gespeichert werden.', ['status' => 500]); + } + // Verzeichnisliste verhindern + if (!file_exists($ordner . '/index.html')) { + file_put_contents($ordner . '/index.html', ''); + } + $name = bin2hex(random_bytes(16)) . '.' . $endung; + if (file_put_contents($ordner . '/' . $name, $daten) === false) { + return new WP_Error('aeromc_error', 'Bild konnte nicht gespeichert werden.', ['status' => 500]); + } + return trailingslashit($uploads['baseurl']) . AEROMC_FRIENDS_IMAGE_DIR . '/' . $name; +} + +/** Bilddatei zu einer gespeicherten URL löschen (nur aus unserem Ordner). */ +function aeromc_friends_delete_image($url) { + if (!$url) { + return; + } + $name = basename((string) $url); + if (!preg_match('/^[a-f0-9]{32}\.(jpg|png)$/', $name)) { + return; + } + $uploads = wp_upload_dir(); + $datei = trailingslashit($uploads['basedir']) . AEROMC_FRIENDS_IMAGE_DIR . '/' . $name; + if (file_exists($datei)) { + @unlink($datei); + } +} +// Fehlerberichte: Größe des mitgeschickten Berichts, höchstens so viele pro Stunde und Absender. +define('AEROMC_FRIENDS_REPORT_MAX_BYTES', 200000); +define('AEROMC_FRIENDS_REPORTS_PER_HOUR', 5); + +function aeromc_friends_messages_table() { + global $wpdb; + return $wpdb->prefix . AEROMC_FRIENDS_MESSAGES_TABLE; +} + +function aeromc_friends_reports_table() { + global $wpdb; + return $wpdb->prefix . AEROMC_FRIENDS_REPORTS_TABLE; +} + +function aeromc_friends_sessions_table() { + global $wpdb; + return $wpdb->prefix . AEROMC_FRIENDS_SESSIONS_TABLE; +} + +function aeromc_friends_shares_table() { + global $wpdb; + return $wpdb->prefix . AEROMC_FRIENDS_SHARES_TABLE; +} + +/** + * Prüft, ob die Anfrage wirklich von diesem Spieler kommt. + * Gibt die Sitzung (Objekt mit username) zurück, null im Übergangsmodus + * ohne Token, oder WP_Error (401), wenn die Anmeldung fehlt oder ungültig ist. + */ +function aeromc_friends_check_auth($request, $uuid) { + global $wpdb; + $table = aeromc_friends_sessions_table(); + $token = (string) $request->get_header('x-aeromc-token'); + if ($token !== '') { + $hash = hash('sha256', $token); + $grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_SESSION_DAYS * 86400); + $sitzung = $wpdb->get_row($wpdb->prepare( + "SELECT uuid, username, last_used FROM $table WHERE token_hash = %s AND last_used >= %s", $hash, $grenze + )); + if (!$sitzung || $sitzung->uuid !== $uuid) { + return new WP_Error('aeromc_auth', 'Anmeldung ungültig oder abgelaufen.', ['status' => 401]); + } + // "zuletzt benutzt" höchstens stündlich schreiben + if (time() - strtotime($sitzung->last_used) > 3600) { + $wpdb->update($table, ['last_used' => current_time('mysql', true)], ['token_hash' => $hash]); + } + return $sitzung; + } + // ohne Token: nur im Übergangsmodus und nur, solange dieser Spieler sich + // noch nie angemeldet hat - sonst könnte man sich weiter als er ausgeben + $schon_angemeldet = $wpdb->get_var($wpdb->prepare("SELECT 1 FROM $table WHERE uuid = %s LIMIT 1", $uuid)); + if (AEROMC_FRIENDS_REQUIRE_AUTH || $schon_angemeldet) { + return new WP_Error('aeromc_auth', 'Anmeldung erforderlich.', ['status' => 401]); + } + return null; +} + +function aeromc_friends_invites_table() { + global $wpdb; + return $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE; +} + +/** Server-Adresse (Host oder IP, optional :Port) - nur harmlose Zeichen. */ +function aeromc_friends_valid_server($server) { + return is_string($server) && preg_match('/^[A-Za-z0-9.\-_:\[\]]{1,128}$/', $server); +} + +/** Minecraft-Version wie "1.21.1", "24w14a" oder "1.20.1-pre2". */ +function aeromc_friends_clean_version($version) { + $version = (string) $version; + return preg_match('/^[A-Za-z0-9.\-_ ]{1,32}$/', $version) ? $version : ''; +} + +/** Sind die beiden befreundet (Anfrage angenommen)? */ +function aeromc_friends_are_friends($a, $b) { + global $wpdb; + $friends_table = aeromc_friends_friends_table(); + return (bool) $wpdb->get_var($wpdb->prepare( + "SELECT id FROM $friends_table WHERE ((uuid_a = %s AND uuid_b = %s) OR (uuid_a = %s AND uuid_b = %s)) AND status = 'accepted'", + $a, $b, $b, $a + )); +} + +function aeromc_friends_presence_table() { + global $wpdb; + return $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE; +} + +function aeromc_friends_friends_table() { + global $wpdb; + return $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE; +} + +/** Minecraft-UUIDs kommen mal mit, mal ohne Bindestriche an - einheitlich + * ohne Bindestriche, klein geschrieben speichern/vergleichen. Gibt false + * zurück, wenn es keine gültige UUID (32 Hex-Zeichen) ist. */ +function aeromc_friends_normalize_uuid($uuid) { + $stripped = strtolower(str_replace('-', '', (string) $uuid)); + if (!preg_match('/^[a-f0-9]{32}$/', $stripped)) { + return false; + } + return $stripped; +} + +/** Minecraft-Namensregeln: 1-16 Zeichen, Buchstaben/Ziffern/Unterstrich. */ +function aeromc_friends_valid_username($name) { + return is_string($name) && preg_match('/^[A-Za-z0-9_]{1,16}$/', $name); +} + +add_action('rest_api_init', function () { + register_rest_route('aeromc/v1', '/auth/challenge', [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_auth_challenge', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/auth/verify', [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_auth_verify', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/share', [ + [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_share_create', + 'permission_callback' => '__return_true', + ], + [ + 'methods' => 'GET', + 'callback' => 'aeromc_friends_route_share_get', + 'permission_callback' => '__return_true', + ], + ]); + register_rest_route('aeromc/v1', '/friends/message', [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_message_send', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends/messages', [ + 'methods' => 'GET', + 'callback' => 'aeromc_friends_route_messages', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends/messages/unread', [ + 'methods' => 'GET', + 'callback' => 'aeromc_friends_route_messages_unread', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends/messages/read', [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_messages_read', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/report', [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_report', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/presence', [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_presence', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends', [ + 'methods' => 'GET', + 'callback' => 'aeromc_friends_route_list', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends/requests', [ + 'methods' => 'GET', + 'callback' => 'aeromc_friends_route_requests', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends/sent', [ + 'methods' => 'GET', + 'callback' => 'aeromc_friends_route_sent', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends/request', [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_request', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends/respond', [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_respond', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends/cancel', [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_cancel', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends/remove', [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_remove', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends/invite', [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_invite', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends/invites', [ + 'methods' => 'GET', + 'callback' => 'aeromc_friends_route_invites', + 'permission_callback' => '__return_true', + ]); + register_rest_route('aeromc/v1', '/friends/invites/dismiss', [ + 'methods' => 'POST', + 'callback' => 'aeromc_friends_route_invite_dismiss', + 'permission_callback' => '__return_true', + ]); +}); + +/** POST /presence {uuid, username, instance, server, version} */ +function aeromc_friends_route_presence($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + $username = $request->get_param('username'); + if (!$uuid || !aeromc_friends_valid_username($username)) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid/username.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + if ($sitzung) { + $username = $sitzung->username; // von Mojang bestätigt + } + $instance = sanitize_text_field((string) $request->get_param('instance')); + $server = sanitize_text_field((string) $request->get_param('server')); + $version = aeromc_friends_clean_version($request->get_param('version')); + $table = aeromc_friends_presence_table(); + + $letzte = $wpdb->get_row($wpdb->prepare( + "SELECT last_seen, server_label FROM $table WHERE uuid = %s", $uuid + )); + $server_gleich = $letzte && (string) $letzte->server_label === (string) $server; + if ($letzte && $server_gleich && (time() - strtotime($letzte->last_seen)) < AEROMC_FRIENDS_HEARTBEAT_MIN_GAP) { + return ['ok' => true]; // zu häufig - stillschweigend ignoriert + } + + // current_time('mysql', true) statt current_time('mysql'): das zweite + // Argument erzwingt GMT/UTC. Ohne das liefert current_time() die an die + // WordPress-Zeitzone angepasste ORTSZEIT (Einstellungen > Allgemein) - + // das online/offline-Fenster unten vergleicht last_seen aber per + // strtotime()/time() als UTC, wodurch ein Freund bei abweichender + // Zeitzone praktisch nie als offline erkannt worden wäre. + $jetzt_utc = current_time('mysql', true); + $wpdb->query($wpdb->prepare( + "INSERT INTO $table (uuid, username, instance_name, server_label, mc_version, last_seen) + VALUES (%s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE username = %s, instance_name = %s, server_label = %s, mc_version = %s, last_seen = %s", + $uuid, $username, $instance ?: null, $server ?: null, $version ?: null, $jetzt_utc, + $username, $instance ?: null, $server ?: null, $version ?: null, $jetzt_utc + )); + return ['ok' => true]; +} + +/** GET /friends?uuid= - akzeptierte Freunde inkl. Online-Status */ +function aeromc_friends_route_list($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + if (!$uuid) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $friends_table = aeromc_friends_friends_table(); + $presence_table = aeromc_friends_presence_table(); + + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT IF(f.uuid_a = %s, f.uuid_b, f.uuid_a) AS other_uuid + FROM $friends_table f + WHERE (f.uuid_a = %s OR f.uuid_b = %s) AND f.status = 'accepted'", + $uuid, $uuid, $uuid + )); + + $ergebnis = []; + foreach ($rows as $row) { + $p = $wpdb->get_row($wpdb->prepare( + "SELECT username, instance_name, server_label, mc_version, last_seen FROM $presence_table WHERE uuid = %s", + $row->other_uuid + )); + $online = $p && (time() - strtotime($p->last_seen)) < AEROMC_FRIENDS_ONLINE_SECONDS; + $ergebnis[] = [ + 'uuid' => $row->other_uuid, + 'username' => $p ? $p->username : null, + 'online' => (bool) $online, + 'instance' => $online ? $p->instance_name : null, + 'server' => $online ? $p->server_label : null, + 'version' => $online ? $p->mc_version : null, + ]; + } + return $ergebnis; +} + +/** GET /friends/requests?uuid= - offene, an mich gerichtete Anfragen */ +function aeromc_friends_route_requests($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + if (!$uuid) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $friends_table = aeromc_friends_friends_table(); + $presence_table = aeromc_friends_presence_table(); + + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT f.uuid_a AS from_uuid, p.username AS from_username + FROM $friends_table f + LEFT JOIN $presence_table p ON p.uuid = f.uuid_a + WHERE f.uuid_b = %s AND f.status = 'pending'", + $uuid + )); + return array_map(function ($r) { + return ['uuid' => $r->from_uuid, 'username' => $r->from_username]; + }, $rows); +} + +/** GET /friends/sent?uuid= - offene, von mir SELBST gesendete Anfragen */ +function aeromc_friends_route_sent($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + if (!$uuid) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $friends_table = aeromc_friends_friends_table(); + $presence_table = aeromc_friends_presence_table(); + + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT f.uuid_b AS to_uuid, p.username AS to_username + FROM $friends_table f + LEFT JOIN $presence_table p ON p.uuid = f.uuid_b + WHERE f.uuid_a = %s AND f.status = 'pending'", + $uuid + )); + return array_map(function ($r) { + return ['uuid' => $r->to_uuid, 'username' => $r->to_username]; + }, $rows); +} + +/** POST /friends/request {uuid, username, targetUsername} */ +function aeromc_friends_route_request($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + $targetUsername = $request->get_param('targetUsername'); + if (!$uuid || !aeromc_friends_valid_username($targetUsername)) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid/targetUsername.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $presence_table = aeromc_friends_presence_table(); + $friends_table = aeromc_friends_friends_table(); + + // Namen des Absenders merken: Bisher kannte der Server ihn erst nach dem + // ersten Spielen über AeroMC (Herzschlag) – vorher sah der Empfänger nur + // die UUID. Bestehende Einträge behalten ihren Status, neue gelten als + // offline (last_seen in der Vergangenheit), damit niemand fälschlich + // "online" erscheint. + $eigener_name = ($sitzung && !empty($sitzung->username)) ? $sitzung->username : $request->get_param('username'); + if (aeromc_friends_valid_username($eigener_name)) { + $wpdb->query($wpdb->prepare( + "INSERT INTO $presence_table (uuid, username, last_seen) VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE username = %s", + $uuid, $eigener_name, '2000-01-01 00:00:00', $eigener_name + )); + } + + $ziel = $wpdb->get_row($wpdb->prepare( + "SELECT uuid FROM $presence_table WHERE username = %s", $targetUsername + )); + if (!$ziel) { + return new WP_Error('aeromc_not_found', 'Dieser Name wurde noch nie im Launcher gesehen.', ['status' => 404]); + } + $ziel_uuid = $ziel->uuid; + if ($ziel_uuid === $uuid) { + return new WP_Error('aeromc_self', 'Du kannst dich nicht selbst hinzufügen.', ['status' => 400]); + } + + $vorhanden = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM $friends_table WHERE (uuid_a = %s AND uuid_b = %s) OR (uuid_a = %s AND uuid_b = %s)", + $uuid, $ziel_uuid, $ziel_uuid, $uuid + )); + if ($vorhanden) { + return new WP_Error('aeromc_exists', 'Es gibt schon eine Anfrage oder Freundschaft.', ['status' => 409]); + } + + $wpdb->insert($friends_table, [ + 'uuid_a' => $uuid, + 'uuid_b' => $ziel_uuid, + 'status' => 'pending', + 'created_at' => current_time('mysql'), + ]); + return ['ok' => true]; +} + +/** POST /friends/respond {uuid, otherUuid, accept} */ +function aeromc_friends_route_respond($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + $other = aeromc_friends_normalize_uuid($request->get_param('otherUuid')); + $accept = (bool) $request->get_param('accept'); + if (!$uuid || !$other) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid/otherUuid.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $friends_table = aeromc_friends_friends_table(); + + if ($accept) { + $wpdb->query($wpdb->prepare( + "UPDATE $friends_table SET status = 'accepted' WHERE uuid_a = %s AND uuid_b = %s AND status = 'pending'", + $other, $uuid + )); + } else { + $wpdb->query($wpdb->prepare( + "DELETE FROM $friends_table WHERE uuid_a = %s AND uuid_b = %s AND status = 'pending'", + $other, $uuid + )); + } + return ['ok' => true]; +} + +/** POST /friends/cancel {uuid, otherUuid} - eine SELBST gesendete Anfrage zurückziehen */ +function aeromc_friends_route_cancel($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + $other = aeromc_friends_normalize_uuid($request->get_param('otherUuid')); + if (!$uuid || !$other) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid/otherUuid.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $friends_table = aeromc_friends_friends_table(); + $wpdb->query($wpdb->prepare( + "DELETE FROM $friends_table WHERE uuid_a = %s AND uuid_b = %s AND status = 'pending'", + $uuid, $other + )); + return ['ok' => true]; +} + +/** POST /friends/remove {uuid, otherUuid} */ +function aeromc_friends_route_remove($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + $other = aeromc_friends_normalize_uuid($request->get_param('otherUuid')); + if (!$uuid || !$other) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid/otherUuid.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $friends_table = aeromc_friends_friends_table(); + $wpdb->query($wpdb->prepare( + "DELETE FROM $friends_table WHERE ((uuid_a = %s AND uuid_b = %s) OR (uuid_a = %s AND uuid_b = %s)) AND status = 'accepted'", + $uuid, $other, $other, $uuid + )); + return ['ok' => true]; +} + +/** POST /friends/invite {uuid, targetUuid, server, version} - "Komm auf meinen Server" */ +function aeromc_friends_route_invite($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + $target = aeromc_friends_normalize_uuid($request->get_param('targetUuid')); + $server = (string) $request->get_param('server'); + $version = aeromc_friends_clean_version($request->get_param('version')); + if (!$uuid || !$target || !aeromc_friends_valid_server($server)) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid/targetUuid/server.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + if (!aeromc_friends_are_friends($uuid, $target)) { + return new WP_Error('aeromc_not_friends', 'Ihr seid nicht befreundet.', ['status' => 403]); + } + $table = aeromc_friends_invites_table(); + + $letzte = $wpdb->get_var($wpdb->prepare( + "SELECT created_at FROM $table WHERE from_uuid = %s AND to_uuid = %s ORDER BY id DESC LIMIT 1", + $uuid, $target + )); + if ($letzte && (time() - strtotime($letzte)) < AEROMC_FRIENDS_INVITE_MIN_GAP) { + return new WP_Error('aeromc_too_fast', 'Du hast diesen Freund gerade erst eingeladen.', ['status' => 429]); + } + + // ältere Einladung an denselben Freund ersetzen + $wpdb->query($wpdb->prepare( + "DELETE FROM $table WHERE from_uuid = %s AND to_uuid = %s", $uuid, $target + )); + $wpdb->insert($table, [ + 'from_uuid' => $uuid, + 'to_uuid' => $target, + 'server' => $server, + 'mc_version' => $version ?: null, + 'created_at' => current_time('mysql', true), + ]); + return ['ok' => true]; +} + +/** GET /friends/invites?uuid= - an mich gerichtete, noch gültige Einladungen */ +function aeromc_friends_route_invites($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + if (!$uuid) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $table = aeromc_friends_invites_table(); + $presence_table = aeromc_friends_presence_table(); + + // abgelaufene Einladungen (aller Spieler) wegräumen + $grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_INVITE_SECONDS); + $wpdb->query($wpdb->prepare("DELETE FROM $table WHERE created_at < %s", $grenze)); + + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT i.id, i.from_uuid, p.username AS from_username, i.server, i.mc_version, i.created_at + FROM $table i + LEFT JOIN $presence_table p ON p.uuid = i.from_uuid + WHERE i.to_uuid = %s + ORDER BY i.id DESC", + $uuid + )); + return array_map(function ($r) { + return [ + 'id' => (int) $r->id, + 'fromUuid' => $r->from_uuid, + 'fromUsername' => $r->from_username, + 'server' => $r->server, + 'version' => $r->mc_version, + 'createdAt' => $r->created_at, + ]; + }, $rows); +} + +/** POST /friends/invites/dismiss {uuid, id} - Einladung erledigt/abgelehnt */ +function aeromc_friends_route_invite_dismiss($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + $id = (int) $request->get_param('id'); + if (!$uuid || $id <= 0) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid/id.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $table = aeromc_friends_invites_table(); + $wpdb->query($wpdb->prepare("DELETE FROM $table WHERE id = %d AND to_uuid = %s", $id, $uuid)); + return ['ok' => true]; +} + +// --------------------------------------------------------------------------- +// Anmeldung über Mojangs Sitzungsprüfung +// --------------------------------------------------------------------------- + +/** POST /auth/challenge {uuid, username} - Aufgabe für die Anmeldung bei Mojang */ +function aeromc_friends_route_auth_challenge($request) { + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + $username = $request->get_param('username'); + if (!$uuid || !aeromc_friends_valid_username($username)) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid/username.', ['status' => 400]); + } + $server_id = bin2hex(random_bytes(20)); + set_transient('aeromc_ch_' . $uuid, ['sid' => $server_id, 'name' => $username], 120); + return ['serverId' => $server_id]; +} + +/** POST /auth/verify {uuid} - fragt bei Mojang nach und gibt ein Sitzungs-Token aus */ +function aeromc_friends_route_auth_verify($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + if (!$uuid) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); + } + $aufgabe = get_transient('aeromc_ch_' . $uuid); + delete_transient('aeromc_ch_' . $uuid); // nur einmal verwendbar + if (!is_array($aufgabe) || empty($aufgabe['sid'])) { + return new WP_Error('aeromc_auth', 'Keine oder abgelaufene Anmelde-Aufgabe.', ['status' => 403]); + } + $url = 'https://sessionserver.mojang.com/session/minecraft/hasJoined?username=' + . rawurlencode($aufgabe['name']) . '&serverId=' . rawurlencode($aufgabe['sid']); + $antwort = wp_remote_get($url, ['timeout' => 10]); + if (is_wp_error($antwort) || wp_remote_retrieve_response_code($antwort) !== 200) { + return new WP_Error('aeromc_auth', 'Mojang hat die Anmeldung nicht bestätigt.', ['status' => 403]); + } + $profil = json_decode(wp_remote_retrieve_body($antwort), true); + if (!is_array($profil) || aeromc_friends_normalize_uuid($profil['id'] ?? '') !== $uuid + || !aeromc_friends_valid_username($profil['name'] ?? '')) { + return new WP_Error('aeromc_auth', 'Mojang hat einen anderen Spieler gemeldet.', ['status' => 403]); + } + + $table = aeromc_friends_sessions_table(); + // abgelaufene Sitzungen (aller Spieler) wegräumen + $grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_SESSION_DAYS * 86400); + $wpdb->query($wpdb->prepare("DELETE FROM $table WHERE last_used < %s", $grenze)); + + $token = bin2hex(random_bytes(32)); + $jetzt = current_time('mysql', true); + $wpdb->insert($table, [ + 'token_hash' => hash('sha256', $token), + 'uuid' => $uuid, + 'username' => $profil['name'], + 'created_at' => $jetzt, + 'last_used' => $jetzt, + ]); + // Bestätigten Namen gleich merken – so ist ein Spieler per Name auffindbar, + // sobald er AeroMC mit Freundesystem nutzt, nicht erst nach dem ersten + // Spielen. Neue Einträge gelten als offline (last_seen in der Vergangenheit). + $presence_table = aeromc_friends_presence_table(); + $wpdb->query($wpdb->prepare( + "INSERT INTO $presence_table (uuid, username, last_seen) VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE username = %s", + $uuid, $profil['name'], '2000-01-01 00:00:00', $profil['name'] + )); + return ['token' => $token, 'username' => $profil['name']]; +} + +// --------------------------------------------------------------------------- +// Instanzen per Code teilen +// --------------------------------------------------------------------------- + +/** Code ohne verwechselbare Zeichen (kein 0/O, 1/I/L), Form XXXX-XXXX. */ +function aeromc_friends_new_share_code() { + $zeichen = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + $code = ''; + for ($i = 0; $i < 8; $i++) { + $code .= $zeichen[random_int(0, strlen($zeichen) - 1)]; + if ($i === 3) { + $code .= '-'; + } + } + return $code; +} + +/** Eingegebenen Code vereinheitlichen ("ab12 cd34" -> "AB12-CD34"), false wenn ungültig. */ +function aeromc_friends_normalize_share_code($code) { + $roh = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string) $code)); + if (strlen($roh) !== 8) { + return false; + } + return substr($roh, 0, 4) . '-' . substr($roh, 4); +} + +/** POST /share {uuid, manifest} - Mod-Liste einer Instanz hinterlegen, Code zurück */ +function aeromc_friends_route_share_create($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + if (!$uuid) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $manifest = $request->get_param('manifest'); + if (!is_array($manifest) || empty($manifest['minecraft']) || !isset($manifest['inhalte'])) { + return new WP_Error('aeromc_invalid', 'Ungültige Instanz-Beschreibung.', ['status' => 400]); + } + $json = wp_json_encode($manifest); + if (!$json || strlen($json) > AEROMC_FRIENDS_SHARE_MAX_BYTES) { + return new WP_Error('aeromc_too_big', 'Die Instanz ist zu groß zum Teilen.', ['status' => 413]); + } + $table = aeromc_friends_shares_table(); + + $seit = gmdate('Y-m-d H:i:s', time() - 86400); + $heute = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM $table WHERE uuid = %s AND created_at >= %s", $uuid, $seit + )); + if ($heute >= AEROMC_FRIENDS_SHARES_PER_DAY) { + return new WP_Error('aeromc_too_fast', 'Heute wurden schon sehr viele Instanzen geteilt – bitte morgen wieder.', ['status' => 429]); + } + + // abgelaufene Codes (aller Spieler) wegräumen + $grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_SHARE_DAYS * 86400); + $wpdb->query($wpdb->prepare("DELETE FROM $table WHERE created_at < %s", $grenze)); + + for ($versuch = 0; $versuch < 5; $versuch++) { + $code = aeromc_friends_new_share_code(); + $ok = $wpdb->insert($table, [ + 'code' => $code, + 'uuid' => $uuid, + 'manifest' => $json, + 'created_at' => current_time('mysql', true), + ]); + if ($ok) { + return ['code' => $code, 'days' => AEROMC_FRIENDS_SHARE_DAYS]; + } + } + return new WP_Error('aeromc_error', 'Es konnte kein Code erzeugt werden.', ['status' => 500]); +} + +/** GET /share?code= - geteilte Instanz abrufen (ohne Anmeldung: wer den Code hat, darf sie sehen) */ +function aeromc_friends_route_share_get($request) { + global $wpdb; + $code = aeromc_friends_normalize_share_code($request->get_param('code')); + if (!$code) { + return new WP_Error('aeromc_invalid', 'Ungültiger Code.', ['status' => 400]); + } + $table = aeromc_friends_shares_table(); + $presence_table = aeromc_friends_presence_table(); + $grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_SHARE_DAYS * 86400); + $row = $wpdb->get_row($wpdb->prepare( + "SELECT s.code, s.uuid, s.manifest, s.created_at, p.username + FROM $table s LEFT JOIN $presence_table p ON p.uuid = s.uuid + WHERE s.code = %s AND s.created_at >= %s", + $code, $grenze + )); + if (!$row) { + return new WP_Error('aeromc_not_found', 'Diesen Code gibt es nicht oder er ist abgelaufen.', ['status' => 404]); + } + return [ + 'code' => $row->code, + 'fromUuid' => $row->uuid, + 'fromUsername' => $row->username, + 'createdAt' => $row->created_at, + 'manifest' => json_decode($row->manifest, true), + ]; +} + +// --------------------------------------------------------------------------- +// Kurznachrichten zwischen Freunden +// --------------------------------------------------------------------------- + +/** POST /friends/message {uuid, targetUuid, text} */ +function aeromc_friends_route_message_send($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + $target = aeromc_friends_normalize_uuid($request->get_param('targetUuid')); + if (!$uuid || !$target) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid/targetUuid.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $text = trim(sanitize_text_field((string) $request->get_param('text'))); + $bild = $request->get_param('image'); + if ($text === '' && !$bild) { + return new WP_Error('aeromc_invalid', 'Leere Nachricht.', ['status' => 400]); + } + if (mb_strlen($text) > AEROMC_FRIENDS_MESSAGE_MAX_CHARS) { + $text = mb_substr($text, 0, AEROMC_FRIENDS_MESSAGE_MAX_CHARS); + } + if (!aeromc_friends_are_friends($uuid, $target)) { + return new WP_Error('aeromc_not_friends', 'Ihr seid nicht befreundet.', ['status' => 403]); + } + $table = aeromc_friends_messages_table(); + + $seit = gmdate('Y-m-d H:i:s', time() - 60); + $letzte_minute = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM $table WHERE from_uuid = %s AND created_at >= %s", $uuid, $seit + )); + if ($letzte_minute >= AEROMC_FRIENDS_MESSAGES_PER_MINUTE) { + return new WP_Error('aeromc_too_fast', 'Zu viele Nachrichten – bitte kurz warten.', ['status' => 429]); + } + + // alte Nachrichten (aller Spieler) samt ihrer Bilder wegräumen + $grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_MESSAGE_DAYS * 86400); + $alte_bilder = $wpdb->get_col($wpdb->prepare( + "SELECT image_url FROM $table WHERE created_at < %s AND image_url IS NOT NULL", $grenze + )); + foreach ($alte_bilder as $alt) { + aeromc_friends_delete_image($alt); + } + $wpdb->query($wpdb->prepare("DELETE FROM $table WHERE created_at < %s", $grenze)); + + $bild_url = null; + if ($bild) { + $bild_url = aeromc_friends_save_image($bild); + if (is_wp_error($bild_url)) { + return $bild_url; + } + } + + $wpdb->insert($table, [ + 'from_uuid' => $uuid, + 'to_uuid' => $target, + 'text' => $text, + 'image_url' => $bild_url, + 'created_at' => current_time('mysql', true), + ]); + return ['ok' => true, 'id' => (int) $wpdb->insert_id]; +} + +/** GET /friends/messages?uuid=&with= - Unterhaltung mit einem Freund (älteste zuerst, höchstens 50) */ +function aeromc_friends_route_messages($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + $other = aeromc_friends_normalize_uuid($request->get_param('with')); + if (!$uuid || !$other) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid/with.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $table = aeromc_friends_messages_table(); + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT id, from_uuid, text, image_url, created_at, read_at FROM $table + WHERE (from_uuid = %s AND to_uuid = %s) OR (from_uuid = %s AND to_uuid = %s) + ORDER BY id DESC LIMIT 50", + $uuid, $other, $other, $uuid + )); + return array_reverse(array_map(function ($r) { + return [ + 'id' => (int) $r->id, + 'fromUuid' => $r->from_uuid, + 'text' => $r->text, + 'imageUrl' => $r->image_url, + 'createdAt' => $r->created_at, + 'read' => $r->read_at !== null, + ]; + }, $rows)); +} + +/** GET /friends/messages/unread?uuid= - ungelesene Nachrichten an mich */ +function aeromc_friends_route_messages_unread($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + if (!$uuid) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $table = aeromc_friends_messages_table(); + $presence_table = aeromc_friends_presence_table(); + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT m.id, m.from_uuid, p.username AS from_username, m.text, m.image_url, m.created_at + FROM $table m LEFT JOIN $presence_table p ON p.uuid = m.from_uuid + WHERE m.to_uuid = %s AND m.read_at IS NULL + ORDER BY m.id ASC LIMIT 100", + $uuid + )); + return array_map(function ($r) { + return [ + 'id' => (int) $r->id, + 'fromUuid' => $r->from_uuid, + 'fromUsername' => $r->from_username, + 'text' => $r->text, + 'imageUrl' => $r->image_url, + 'createdAt' => $r->created_at, + ]; + }, $rows); +} + +/** POST /friends/messages/read {uuid, fromUuid} - Nachrichten eines Freundes als gelesen markieren */ +function aeromc_friends_route_messages_read($request) { + global $wpdb; + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + $from = aeromc_friends_normalize_uuid($request->get_param('fromUuid')); + if (!$uuid || !$from) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid/fromUuid.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $table = aeromc_friends_messages_table(); + $wpdb->query($wpdb->prepare( + "UPDATE $table SET read_at = %s WHERE to_uuid = %s AND from_uuid = %s AND read_at IS NULL", + current_time('mysql', true), $uuid, $from + )); + return ['ok' => true]; +} + +// --------------------------------------------------------------------------- +// Fehlerberichte aus dem Absturz-Assistenten +// --------------------------------------------------------------------------- + +/** Kurzer Text: Steuerzeichen raus, auf Länge kürzen. */ +function aeromc_friends_kurz($wert, $max) { + $text = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', (string) $wert)); + return mb_substr($text, 0, $max); +} + +/** + * POST /report {launcherVersion, mcVersion, loader, loaderVersion, system, funde[], mods[], bericht, kommentar, uuid?} + * Ohne Anmeldung möglich (der Nutzer kann anonym senden); mit uuid wird die + * Anmeldung geprüft, damit niemand Berichte unter fremdem Namen einreicht. + */ +function aeromc_friends_route_report($request) { + global $wpdb; + // Schutz gegen Fluten: pro Absender-Adresse begrenzt + $ip = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : ''; + $schluessel = 'aeromc_rep_' . md5($ip); + $anzahl = (int) get_transient($schluessel); + if ($anzahl >= AEROMC_FRIENDS_REPORTS_PER_HOUR) { + return new WP_Error('aeromc_too_fast', 'Zu viele Berichte in kurzer Zeit – bitte später noch einmal.', ['status' => 429]); + } + + $uuid = null; + $username = null; + if ($request->get_param('uuid')) { + $uuid = aeromc_friends_normalize_uuid($request->get_param('uuid')); + if (!$uuid) { + return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]); + } + $sitzung = aeromc_friends_check_auth($request, $uuid); + if (is_wp_error($sitzung)) { + return $sitzung; + } + $username = $sitzung ? $sitzung->username : aeromc_friends_kurz($request->get_param('username'), 32); + } + + $bericht = (string) $request->get_param('bericht'); + if (strlen($bericht) > AEROMC_FRIENDS_REPORT_MAX_BYTES) { + $bericht = substr($bericht, -AEROMC_FRIENDS_REPORT_MAX_BYTES); + } + $funde = $request->get_param('funde'); + $mods = $request->get_param('mods'); + + $wpdb->insert(aeromc_friends_reports_table(), [ + 'created_at' => current_time('mysql', true), + 'uuid' => $uuid, + 'username' => $username ?: null, + 'launcher_version' => aeromc_friends_kurz($request->get_param('launcherVersion'), 32), + 'mc_version' => aeromc_friends_kurz($request->get_param('mcVersion'), 32), + 'loader' => aeromc_friends_kurz($request->get_param('loader'), 32), + 'loader_version' => aeromc_friends_kurz($request->get_param('loaderVersion'), 64), + 'system' => aeromc_friends_kurz($request->get_param('system'), 128), + 'funde' => wp_json_encode(is_array($funde) ? array_slice($funde, 0, 20) : []), + 'mods' => wp_json_encode(is_array($mods) ? array_slice($mods, 0, 500) : []), + 'bericht' => $bericht, + 'kommentar' => mb_substr(sanitize_textarea_field((string) $request->get_param('kommentar')), 0, 2000), + 'status' => 'neu', + ]); + set_transient($schluessel, $anzahl + 1, HOUR_IN_SECONDS); + return ['ok' => true, 'id' => (int) $wpdb->insert_id]; +}