Files
AeroMc-Launcher/wordpress-plugin/aeromc-friends/includes/routes.php
T
2026-10-02 19:39:46 +00:00

1028 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* REST-Routen unter /wp-json/aeromc/v1/... für das Freundesystem.
*
* 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.
*/
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];
}