diff --git a/installer/Installer.cs b/installer/Installer.cs index 28d6f9a..b45863f 100644 --- a/installer/Installer.cs +++ b/installer/Installer.cs @@ -268,6 +268,8 @@ public static class Installer RemoveAutostart(exe); Registry.CurrentUser.DeleteSubKeyTree(UninstallKey, throwOnMissingSubKey: false); Registry.CurrentUser.DeleteSubKeyTree(LegacyAppKey, throwOnMissingSubKey: false); + // Name/Logo für Windows-Benachrichtigungen (legt der Launcher selbst an) + Registry.CurrentUser.DeleteSubKeyTree(@"Software\Classes\AppUserModelId\" + AppUserModelId, throwOnMissingSubKey: false); if (removeData && Directory.Exists(DataDir)) { diff --git a/installer/MainWindow.xaml b/installer/MainWindow.xaml index 56a33ba..a974db6 100644 --- a/installer/MainWindow.xaml +++ b/installer/MainWindow.xaml @@ -1,368 +1,392 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - '; + echo ''; + echo ''; + echo ''; + return; + } + + // Liste + $rows = $wpdb->get_results("SELECT id, created_at, username, launcher_version, mc_version, loader, funde, kommentar, status FROM $table ORDER BY id DESC LIMIT 200"); + if (!$rows) { + echo '

Noch keine Fehlerberichte eingegangen.

'; + return; + } + echo ''; + foreach ($rows as $r) { + $funde = json_decode((string) $r->funde, true) ?: []; + $ursache = $funde ? ($funde[0]['titel'] ?? '') . (count($funde) > 1 ? ' (+' . (count($funde) - 1) . ')' : '') : '–'; + $fett = $r->status === 'neu' ? 'font-weight:600' : ''; + echo '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + echo '
#Eingegangen (UTC)SpielerLauncherMinecraftErkannte UrsacheKommentarStatus
#' . esc_html($r->id) . '' . esc_html($r->created_at) . '' . esc_html($r->username ?: 'anonym') . '' . esc_html($r->launcher_version) . '' . esc_html(trim($r->mc_version . ' ' . $r->loader)) . '' . esc_html($ursache) . '' . esc_html(mb_strimwidth((string) $r->kommentar, 0, 60, '…')) . '' . esc_html($r->status) . '
'; +} diff --git a/wordpress-plugin/aeromc-friends/includes/routes.php b/wordpress-plugin/aeromc-friends/includes/routes.php index a947c16..21d9df0 100644 --- a/wordpress-plugin/aeromc-friends/includes/routes.php +++ b/wordpress-plugin/aeromc-friends/includes/routes.php @@ -2,11 +2,15 @@ /** * REST-Routen unter /wp-json/aeromc/v1/... für das Freundesystem. * - * Bewusst OHNE echte Authentifizierung (Absprache mit dem Nutzer): der - * Launcher meldet sich einfach mit der Minecraft-UUID des aktiven Kontos an. - * Wer eine fremde UUID kennt, könnte sich theoretisch als dieser Spieler - * ausgeben - für den privaten Rahmen dieses Projekts akzeptiert. Eingaben - * werden trotzdem serverseitig geprüft (Format von UUID/Name) und jede + * Anmeldung wie bei Minecraft-Servern: Der Launcher holt sich eine Aufgabe + * (/auth/challenge), meldet sie mit seinem Minecraft-Zugang bei Mojang an + * (sessionserver .../join), und das Plugin fragt bei Mojang nach, ob genau + * dieser Spieler das getan hat (hasJoined). Erst dann gibt es ein Sitzungs- + * Token (Kopfzeile X-AeroMC-Token), das jede Anfrage "als dieser Spieler" + * mitbringen muss - damit kann sich niemand mehr mit einer fremden UUID + * ausgeben. Übergangsmodus siehe AEROMC_FRIENDS_REQUIRE_AUTH. + * + * Eingaben werden serverseitig geprüft (Format von UUID/Name) und jede * Datenbankabfrage läuft ausschließlich über $wpdb->prepare, um SQL-Injection * auszuschließen. */ @@ -27,6 +31,127 @@ 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; @@ -80,6 +205,53 @@ function aeromc_friends_valid_username($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', @@ -145,6 +317,13 @@ function aeromc_friends_route_presence($request) { 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')); @@ -182,6 +361,10 @@ function aeromc_friends_route_list($request) { 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(); @@ -218,6 +401,10 @@ function aeromc_friends_route_requests($request) { 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(); @@ -240,6 +427,10 @@ function aeromc_friends_route_sent($request) { 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(); @@ -263,6 +454,10 @@ function aeromc_friends_route_request($request) { 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(); @@ -303,6 +498,10 @@ function aeromc_friends_route_respond($request) { 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) { @@ -327,6 +526,10 @@ function aeromc_friends_route_cancel($request) { 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'", @@ -343,6 +546,10 @@ function aeromc_friends_route_remove($request) { 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'", @@ -361,6 +568,10 @@ function aeromc_friends_route_invite($request) { 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]); } @@ -395,6 +606,10 @@ function aeromc_friends_route_invites($request) { 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(); @@ -430,7 +645,383 @@ function aeromc_friends_route_invite_dismiss($request) { 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]; +}