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]; }