diff --git a/includes/class-forum-ajax.php b/includes/class-forum-ajax.php index 175dd00..b0a9f6d 100644 --- a/includes/class-forum-ajax.php +++ b/includes/class-forum-ajax.php @@ -2,72 +2,12 @@ if ( ! defined( 'ABSPATH' ) ) exit; class WBF_Ajax { - // ── Discord-Rollen-Sync manuell anstoßen ──────────────────────────────── - public static function handle_manual_discord_sync() { - self::verify(); - $user = WBF_Auth::get_current_user(); - if (!$user || WBF_Roles::level($user->role) < 80) { - wp_send_json_error(['message' => 'Keine Berechtigung.']); - } - if (!function_exists('wbf_run_discord_role_sync')) { - wp_send_json_error(['message' => 'Sync-Funktion nicht gefunden.']); - } - // Sync anstoßen (läuft synchron, kann bei vielen Usern etwas dauern) - wbf_run_discord_role_sync(); - wp_send_json_success(['message' => 'Discord-Rollen-Sync wurde ausgeführt.']); - } - - // ── Discord-Rollen-Sync für einzelnen Nutzer ───────────────────────────── - public static function handle_discord_sync_user() { - self::verify(); - $admin = WBF_Auth::get_current_user(); - if ( ! $admin || WBF_Roles::level( $admin->role ) < 80 ) { - wp_send_json_error( [ 'message' => 'Keine Berechtigung.' ] ); - } - - $target_id = (int) ( $_POST['user_id'] ?? 0 ); - if ( ! $target_id ) { - wp_send_json_error( [ 'message' => 'Keine Nutzer-ID.' ] ); - } - - $s = function_exists( 'wbf_get_settings' ) ? wbf_get_settings() : []; - $token = trim( $s['discord_bot_token'] ?? '' ); - $guild = trim( $s['discord_guild_id'] ?? '' ); - $role_map = json_decode( $s['discord_role_map'] ?? '{}', true ) ?: []; - - if ( ! $token || ! $guild || empty( $role_map ) ) { - wp_send_json_error( [ 'message' => 'Discord nicht konfiguriert.' ] ); - } - - global $wpdb; - $discord_uid = $wpdb->get_var( $wpdb->prepare( - "SELECT meta_value FROM {$wpdb->prefix}forum_user_meta - WHERE user_id = %d AND meta_key = 'discord_user_id'", - $target_id - ) ); - - if ( ! $discord_uid ) { - wp_send_json_error( [ 'message' => 'Nutzer hat kein verknüpftes Discord-Konto.' ] ); - } - - // Beide Richtungen: Discord → Forum - if ( function_exists( 'wbf_sync_discord_role_for_user' ) ) { - wbf_sync_discord_role_for_user( $target_id, $discord_uid, $token, $guild, $role_map ); - } - - // Frisch geladene Rolle zurückgeben damit die UI sofort aktualisiert werden kann - $updated = WBF_DB::get_user( $target_id ); - wp_send_json_success( [ - 'message' => 'Sync abgeschlossen.', - 'new_role' => $updated ? $updated->role : '', - ] ); - } public static function init() { $actions = [ 'wbf_login', 'wbf_register', 'wbf_logout', 'wbf_new_thread', 'wbf_new_post', 'wbf_toggle_like', - 'wbf_update_profile', 'wbf_upload_avatar', 'wbf_upload_banner', 'wbf_upload_post_image', + 'wbf_update_profile', 'wbf_upload_avatar', 'wbf_upload_post_image', 'wbf_upload_banner', 'wbf_forgot_password', 'wbf_reset_password', 'wbf_load_more_messages', 'wbf_create_invite', 'wbf_delete_invite', 'wbf_toggle_subscribe', 'wbf_restore_content', 'wbf_toggle_profile_visibility', @@ -84,8 +24,10 @@ class WBF_Ajax { 'wbf_save_discord', 'wbf_discord_send_code', 'wbf_discord_verify_code', - 'wbf_manual_discord_sync', - 'wbf_discord_sync_user', + 'wbf_2fa_setup_begin', + 'wbf_2fa_setup_verify', + 'wbf_2fa_disable', + 'wbf_2fa_verify_login', ]; foreach ($actions as $action) { add_action('wp_ajax_nopriv_' . $action, [__CLASS__, str_replace('wbf_','handle_',$action)]); @@ -121,7 +63,8 @@ class WBF_Ajax { // Login braucht keinen Nonce — Credentials sind die Authentifizierung $result = WBF_Auth::login( sanitize_text_field($_POST['username'] ?? ''), - $_POST['password'] ?? '' + $_POST['password'] ?? '', + ! empty($_POST['remember_me']) ); if ($result['success']) { // Erfolgreicher Login: Fehlzähler löschen @@ -131,6 +74,9 @@ class WBF_Ajax { WBF_Auth::set_remember_cookie($u->id); } wp_send_json_success(['display_name'=>$u->display_name,'avatar_url'=>$u->avatar_url,'user_id'=>$u->id]); + } elseif ( ! empty($result['2fa_required']) ) { + // 2FA erforderlich — kein Fehlerzähler erhöhen, kein Fehlermeldung + wp_send_json_error(['2fa_required' => true]); } else { // Fehlversuch zählen — außer bei gesperrten Konten (kein Passwortfehler) if ( empty($result['banned']) ) { @@ -202,7 +148,10 @@ class WBF_Ajax { } public static function handle_logout() { - // Kein Nonce-Check für Logout nötig — Session-Clearing ist sicher + // Nonce-Check für Logout + if ( ! isset($_POST['nonce']) || ! check_ajax_referer('wbf_nonce', 'nonce', false) ) { + wp_send_json_error(['message' => 'invalid_nonce'], 403); + } WBF_Auth::logout(); wp_send_json_success(['message' => 'logged_out']); } @@ -250,6 +199,10 @@ class WBF_Ajax { 'content' => WBF_DB::apply_word_filter($content), 'prefix_id' => $prefix_id, ]); + // Ingame-Benachrichtigung + if (function_exists('wbf_notify_ingame')) { + wbf_notify_ingame($user->username, 'Neuer Thread: ' . mb_substr($title, 0, 80)); + } // Tags speichern $raw_tags = sanitize_text_field( $_POST['tags'] ?? '' ); @@ -572,25 +525,21 @@ class WBF_Ajax { wp_send_json_success(['avatar_url'=>$url]); } - // ── Banner Upload ──────────────────────────────────────────────────────── - public static function handle_upload_banner() { self::verify(); $user = WBF_Auth::get_current_user(); - if (!$user) wp_send_json_error(['message'=>'Nicht eingeloggt.']); - if (empty($_FILES['banner'])) wp_send_json_error(['message'=>'Keine Datei.']); + if ( ! $user ) wp_send_json_error(['message' => 'Nicht eingeloggt.']); + if ( empty($_FILES['banner']) ) wp_send_json_error(['message' => 'Keine Datei.']); $allowed_types = ['image/jpeg','image/png','image/gif','image/webp']; - // Max 4 MB für Banner (größer als Avatar) - if ( $_FILES['banner']['size'] > 4 * 1024 * 1024 ) { - wp_send_json_error(['message'=>'Maximale Dateigröße: 4 MB.']); + if ( $_FILES['banner']['size'] > 3 * 1024 * 1024 ) { + wp_send_json_error(['message' => 'Maximale Dateigröße: 3 MB.']); } - // Server-seitige MIME-Typ-Prüfung $tmp = $_FILES['banner']['tmp_name'] ?? ''; if ( ! $tmp || ! is_uploaded_file( $tmp ) ) { - wp_send_json_error(['message'=>'Ungültiger Datei-Upload.']); + wp_send_json_error(['message' => 'Ungültiger Datei-Upload.']); } if ( function_exists('finfo_open') ) { $finfo = finfo_open( FILEINFO_MIME_TYPE ); @@ -603,11 +552,11 @@ class WBF_Ajax { IMAGETYPE_GIF => 'image/gif', IMAGETYPE_WEBP => 'image/webp', ]; - $et = @exif_imagetype( $tmp ); - $real_mime = $et_map[$et] ?? ''; + $et = @exif_imagetype( $tmp ); + $real_mime = $et_map[$et] ?? ''; } if ( ! in_array( $real_mime, $allowed_types, true ) ) { - wp_send_json_error(['message'=>'Nur JPG, PNG, GIF und WebP erlaubt.']); + wp_send_json_error(['message' => 'Nur JPG, PNG, GIF und WebP erlaubt.']); } require_once ABSPATH . 'wp-admin/includes/image.php'; @@ -615,11 +564,11 @@ class WBF_Ajax { require_once ABSPATH . 'wp-admin/includes/media.php'; $id = media_handle_upload('banner', 0); - if (is_wp_error($id)) wp_send_json_error(['message'=>$id->get_error_message()]); + if ( is_wp_error($id) ) wp_send_json_error(['message' => $id->get_error_message()]); $url = wp_get_attachment_url($id); - WBF_DB::update_user($user->id, ['banner_url'=>$url]); - wp_send_json_success(['banner_url'=>$url]); + WBF_DB::update_user($user->id, ['banner_url' => $url]); + wp_send_json_success(['banner_url' => $url]); } // ── Report ──────────────────────────────────────────────────────────────── @@ -1738,6 +1687,143 @@ class WBF_Ajax { return ( ! is_wp_error($msg_res) && wp_remote_retrieve_response_code($msg_res) === 200 ); } + + // ══════════════════════════════════════════════════════════════════════════ + // ── 2FA / TOTP ──────────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════ + + /** + * Setup-Schritt 1: Neues Secret generieren und als "pending" speichern. + * Gibt Secret (zur manuellen Eingabe) und otpauth:// URI (für QR-Code) zurück. + */ + public static function handle_2fa_setup_begin() { + self::verify(); + $user = WBF_Auth::get_current_user(); + if ( ! $user ) wp_send_json_error( ["message" => "Nicht eingeloggt."] ); + if ( ! class_exists("WBF_TOTP") ) wp_send_json_error( ["message" => "2FA-Modul nicht verfügbar."] ); + + $secret = WBF_TOTP::generate_secret(); + WBF_DB::set_user_meta( $user->id, WBF_TOTP::META_PENDING, $secret ); + + wp_send_json_success( [ + "secret" => $secret, + "uri" => WBF_TOTP::get_otpauth_uri( $user->username, $secret ), + ] ); + } + + /** + * Setup-Schritt 2: Code verifizieren und 2FA aktivieren. + */ + public static function handle_2fa_setup_verify() { + self::verify(); + $user = WBF_Auth::get_current_user(); + if ( ! $user ) wp_send_json_error( ["message" => "Nicht eingeloggt."] ); + if ( ! class_exists("WBF_TOTP") ) wp_send_json_error( ["message" => "2FA-Modul nicht verfügbar."] ); + + $code = preg_replace( "/\s+/", "", sanitize_text_field( $_POST["code"] ?? "" ) ); + $secret = WBF_DB::get_user_meta_single( $user->id, WBF_TOTP::META_PENDING ); + + if ( empty($secret) ) { + wp_send_json_error( ["message" => "Kein ausstehender 2FA-Setup. Bitte neu starten."] ); + } + if ( ! WBF_TOTP::verify( $secret, $code ) ) { + wp_send_json_error( ["message" => "Ungültiger Code. Bitte Uhrzeit prüfen und erneut versuchen."] ); + } + + WBF_DB::set_user_meta( $user->id, WBF_TOTP::META_SECRET, $secret ); + global $wpdb; + $wpdb->delete( "{$wpdb->prefix}forum_user_meta", + ["user_id" => $user->id, "meta_key" => WBF_TOTP::META_PENDING], ["%d", "%s"] ); + + wp_send_json_success( ["message" => "2FA erfolgreich aktiviert!"] ); + } + + /** + * 2FA deaktivieren (User-seitig). + * Erfordert aktuelles Passwort + gültigen TOTP-Code. + */ + public static function handle_2fa_disable() { + self::verify(); + $user = WBF_Auth::get_current_user(); + if ( ! $user ) wp_send_json_error( ["message" => "Nicht eingeloggt."] ); + if ( ! class_exists("WBF_TOTP") ) wp_send_json_error( ["message" => "2FA-Modul nicht verfügbar."] ); + + $password = $_POST["password"] ?? ""; + $code = preg_replace( "/\s+/", "", sanitize_text_field( $_POST["code"] ?? "" ) ); + + $fresh = WBF_DB::get_user( $user->id ); + if ( ! $fresh || ! password_verify( $password, $fresh->password ) ) { + wp_send_json_error( ["message" => "Falsches Passwort."] ); + } + + $secret = WBF_DB::get_user_meta_single( $user->id, WBF_TOTP::META_SECRET ); + if ( empty($secret) ) { + wp_send_json_error( ["message" => "2FA ist nicht aktiv."] ); + } + if ( ! WBF_TOTP::verify( $secret, $code ) ) { + wp_send_json_error( ["message" => "Ungültiger Authenticator-Code."] ); + } + + WBF_TOTP::disable_for( $user->id ); + wp_send_json_success( ["message" => "2FA wurde deaktiviert."] ); + } + + /** + * Login-Schritt 2: TOTP-Code nach erfolgreichem Passwort prüfen. + * Kein Nonce — ausstehende Session-ID ist der Auth-Beweis. + * Brute-Force-Schutz: max. 5 Versuche / IP / 10 Min. + */ + public static function handle_2fa_verify_login() { + WBF_Auth::init(); + + $ip_key = "wbf_2fa_fail_" . md5( $_SERVER["REMOTE_ADDR"] ?? "unknown" ); + $fails = (int) get_transient( $ip_key ); + if ( $fails >= 5 ) { + wp_send_json_error( ["message" => "Zu viele Fehlversuche. Bitte warte 10 Minuten.", "locked" => true] ); + } + + $pending = (int) ( $_SESSION[ WBF_TOTP::SESSION_PENDING ] ?? 0 ); + if ( ! $pending ) { + wp_send_json_error( ["message" => "Keine ausstehende Anmeldung. Bitte erneut einloggen."] ); + } + + $code = preg_replace( "/\s+/", "", sanitize_text_field( $_POST["code"] ?? "" ) ); + $user = WBF_DB::get_user( $pending ); + + if ( ! $user ) { + unset( $_SESSION[ WBF_TOTP::SESSION_PENDING ] ); + wp_send_json_error( ["message" => "Ungültige Sitzung."] ); + } + + $secret = WBF_DB::get_user_meta_single( $user->id, WBF_TOTP::META_SECRET ); + if ( empty($secret) || ! WBF_TOTP::verify( $secret, $code ) ) { + set_transient( $ip_key, $fails + 1, 10 * MINUTE_IN_SECONDS ); + wp_send_json_error( ["message" => "Ungültiger Code. Bitte erneut versuchen."] ); + } + + delete_transient( $ip_key ); + unset( $_SESSION[ WBF_TOTP::SESSION_PENDING ] ); + + if ( WBF_Roles::level($user->role) < 0 ) { + wp_send_json_error( ["message" => "Dein Konto ist gesperrt."] ); + } + + if ( session_id() ) session_regenerate_id( true ); + $_SESSION[ WBF_Auth::SESSION_KEY ] = $user->id; + WBF_DB::touch_last_active( $user->id ); + + if ( ! empty( $_SESSION["wbf_2fa_remember"] ) ) { + WBF_Auth::set_remember_cookie( $user->id ); + unset( $_SESSION["wbf_2fa_remember"] ); + } + + wp_send_json_success( [ + "display_name" => $user->display_name, + "avatar_url" => $user->avatar_url, + "user_id" => $user->id, + ] ); + } + } add_action( 'init', [ 'WBF_Ajax', 'init' ] ); \ No newline at end of file diff --git a/includes/class-forum-auth.php b/includes/class-forum-auth.php index 8535b01..63f0527 100644 --- a/includes/class-forum-auth.php +++ b/includes/class-forum-auth.php @@ -3,7 +3,7 @@ if ( ! defined( 'ABSPATH' ) ) exit; class WBF_Auth { - const SESSION_KEY = 'wbf_forum_user'; + const SESSION_KEY = 'wbf_forum_user'; public static function init() { // PHP 8.3: session_start() nach gesendeten Headers erzeugt E_WARNING, @@ -11,8 +11,6 @@ class WBF_Auth { // Lösung: headers_sent() prüfen + session_start() mit Cookie-Optionen aufrufen. if ( ! session_id() ) { if ( headers_sent() ) { - // Headers bereits gesendet — Session kann nicht sicher gestartet werden. - // Passiert z.B. wenn WP_DEBUG=true und PHP Notices vor dem Hook ausgegeben hat. return; } $session_opts = [ @@ -20,7 +18,6 @@ class WBF_Auth { 'cookie_samesite' => 'Lax', 'use_strict_mode' => true, ]; - // cookie_secure nur setzen wenn HTTPS aktiv — verhindert Session-Verlust bei HTTP if ( is_ssl() || ( ! empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ) ) { $session_opts['cookie_secure'] = true; } @@ -50,7 +47,7 @@ class WBF_Auth { return WBF_DB::get_user( (int) $_SESSION[ self::SESSION_KEY ] ); } - public static function login( $username_or_email, $password ) { + public static function login( $username_or_email, $password, $remember = false ) { self::init(); $user = WBF_DB::get_user_by( 'username', $username_or_email ); if ( ! $user ) { @@ -60,6 +57,19 @@ class WBF_Auth { if ( ! password_verify( $password, $user->password ) ) { return array( 'success' => false, 'message' => 'Falsches Passwort.' ); } + + // ── 2FA-Check ───────────────────────────────────────────────────────── + // Wenn 2FA aktiv: Login pausieren und TOTP-Code anfordern. + // remember-Flag in Session merken, damit es nach 2FA-Verifikation gesetzt wird. + if ( class_exists('WBF_TOTP') && WBF_TOTP::is_enabled_for( $user->id ) ) { + $_SESSION[ WBF_TOTP::SESSION_PENDING ] = $user->id; + if ( $remember ) { + $_SESSION['wbf_2fa_remember'] = true; + } + return array( 'success' => false, '2fa_required' => true ); + } + // ── Ende 2FA-Check ──────────────────────────────────────────────────── + if ( WBF_Roles::level($user->role) < 0 ) { // Zeitlich begrenzte Sperre prüfen — automatisch aufheben wenn abgelaufen if ( ! empty($user->ban_until) && strtotime($user->ban_until) <= time() ) { @@ -70,22 +80,20 @@ class WBF_Auth { 'ban_until' => null, 'pre_ban_role' => '', ]); - // Frisch laden und einloggen $user = WBF_DB::get_user( $user->id ); - if ( session_id() ) session_regenerate_id( true ); // Session Fixation verhindern + if ( session_id() ) session_regenerate_id( true ); $_SESSION[ self::SESSION_KEY ] = $user->id; WBF_DB::touch_last_active( $user->id ); return array( 'success' => true, 'user' => $user ); } $reason = !empty($user->ban_reason) ? $user->ban_reason : 'Dein Konto wurde gesperrt.'; - // Zeitstempel anhängen wenn temporäre Sperre if ( ! empty($user->ban_until) ) { $until_fmt = date_i18n( 'd.m.Y \u\m H:i \U\h\r', strtotime($user->ban_until) ); $reason .= ' (Gesperrt bis: ' . $until_fmt . ')'; } return array( 'success' => false, 'banned' => true, 'message' => $reason ); } - if ( session_id() ) session_regenerate_id( true ); // Session Fixation verhindern + if ( session_id() ) session_regenerate_id( true ); $_SESSION[ self::SESSION_KEY ] = $user->id; WBF_DB::touch_last_active( $user->id ); return array( 'success' => true, 'user' => $user ); @@ -115,7 +123,7 @@ class WBF_Auth { 'avatar_url' => $avatar, )); - if ( session_id() ) session_regenerate_id( true ); // Session Fixation verhindern + if ( session_id() ) session_regenerate_id( true ); $_SESSION[ self::SESSION_KEY ] = $id; return array('success'=>true,'user'=>WBF_DB::get_user($id)); } @@ -124,10 +132,14 @@ class WBF_Auth { self::init(); $user_id = $_SESSION[ self::SESSION_KEY ] ?? 0; unset( $_SESSION[ self::SESSION_KEY ] ); + // 2FA-Pending-State ebenfalls löschen + if ( class_exists('WBF_TOTP') ) { + unset( $_SESSION[ WBF_TOTP::SESSION_PENDING ] ); + unset( $_SESSION['wbf_2fa_remember'] ); + } if ( $user_id ) { WBF_DB::delete_remember_token( (int)$user_id ); } - // Remove cookie if ( isset($_COOKIE['wbf_remember']) ) { setcookie( 'wbf_remember', '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); } diff --git a/includes/class-forum-db.php b/includes/class-forum-db.php index c9d128a..91ce1a1 100644 --- a/includes/class-forum-db.php +++ b/includes/class-forum-db.php @@ -151,7 +151,6 @@ class WBF_DB { dbDelta( $sql_threads ); dbDelta( $sql_posts ); dbDelta( $sql_likes ); - dbDelta( $sql_reports ); dbDelta( $sql_tags ); dbDelta( $sql_thread_tags ); dbDelta( $sql_messages ); @@ -201,7 +200,6 @@ class WBF_DB { ) $charset;"; // Ensure reports + notifications tables exist on existing installs - dbDelta( $sql_reports ); dbDelta( $sql_notifications ); // Einladungs-Tabelle @@ -604,10 +602,24 @@ class WBF_DB { ) ); } } + // Posts zählen, User-IDs sammeln + $posts = $wpdb->get_results($wpdb->prepare("SELECT user_id FROM {$wpdb->prefix}forum_posts WHERE thread_id=%d", $id)); + $post_count = count($posts); + $user_post_counts = []; + foreach ($posts as $p) { + $uid = (int)$p->user_id; + if (!isset($user_post_counts[$uid])) $user_post_counts[$uid] = 0; + $user_post_counts[$uid]++; + } + // Posts löschen $wpdb->delete("{$wpdb->prefix}forum_posts", ['thread_id' => $id]); $wpdb->delete("{$wpdb->prefix}forum_threads", ['id' => $id]); + // Zähler anpassen if ( $thread->status !== 'archived' ) { - $wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}forum_categories SET thread_count=GREATEST(thread_count-1,0) WHERE id=%d", $thread->category_id)); + $wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}forum_categories SET thread_count=GREATEST(thread_count-1,0), post_count=GREATEST(post_count-%d,0) WHERE id=%d", $post_count, $thread->category_id)); + } + foreach ($user_post_counts as $uid => $cnt) { + $wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}forum_users SET post_count=GREATEST(post_count-%d,0) WHERE id=%d", $cnt, $uid)); } } @@ -859,6 +871,8 @@ class WBF_DB { 'object_id' => $object_id, 'actor_id' => $actor_id, ] ); + // MC Bridge: Ingame-Benachrichtigung auslösen wenn Spieler verknüpft ist + do_action( 'wbf_notification_created', $user_id, $type, $object_id, $actor_id ); } public static function get_notifications( $user_id, $limit = 20 ) { @@ -1253,12 +1267,13 @@ class WBF_DB { public static function create_remember_token( $user_id ) { global $wpdb; $token = bin2hex( random_bytes(32) ); + $token_hash = hash('sha256', $token); $expires = date('Y-m-d H:i:s', strtotime('+30 days')); // Delete existing tokens for this user first $wpdb->delete( "{$wpdb->prefix}forum_remember_tokens", ['user_id' => $user_id] ); $wpdb->insert( "{$wpdb->prefix}forum_remember_tokens", [ 'user_id' => $user_id, - 'token' => $token, + 'token' => $token_hash, 'expires_at' => $expires, ] ); return $token; @@ -1268,10 +1283,11 @@ class WBF_DB { global $wpdb; $table = "{$wpdb->prefix}forum_remember_tokens"; if ( $wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table ) return null; + $token_hash = hash('sha256', sanitize_text_field($token)); return $wpdb->get_row( $wpdb->prepare( "SELECT user_id FROM {$wpdb->prefix}forum_remember_tokens WHERE token=%s AND expires_at > NOW()", - sanitize_text_field($token) + $token_hash ) ); } @@ -1460,11 +1476,25 @@ class WBF_DB { public static function soft_delete_post( $post_id ) { global $wpdb; + // Soft-Delete setzen $wpdb->update( "{$wpdb->prefix}forum_posts", ['deleted_at' => current_time('mysql')], ['id' => (int)$post_id] ); + // Zähler anpassen + $post = $wpdb->get_row($wpdb->prepare("SELECT thread_id, user_id FROM {$wpdb->prefix}forum_posts WHERE id=%d", $post_id)); + if ($post) { + // Thread reply_count -1 + $wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}forum_threads SET reply_count=GREATEST(reply_count-1,0) WHERE id=%d", $post->thread_id)); + // User post_count -1 + $wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}forum_users SET post_count=GREATEST(post_count-1,0) WHERE id=%d", $post->user_id)); + // Kategorie post_count -1 + $cat_id = $wpdb->get_var($wpdb->prepare("SELECT category_id FROM {$wpdb->prefix}forum_threads WHERE id=%d", $post->thread_id)); + if ($cat_id) { + $wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}forum_categories SET post_count=GREATEST(post_count-1,0) WHERE id=%d", $cat_id)); + } + } } public static function restore_thread( $thread_id ) { @@ -1587,6 +1617,18 @@ class WBF_DB { return $out; } + /** + * Gibt einen einzelnen Meta-Wert zurück (oder leeren String wenn nicht vorhanden). + */ + public static function get_user_meta_single( $user_id, $key ) { + global $wpdb; + $value = $wpdb->get_var( $wpdb->prepare( + "SELECT meta_value FROM {$wpdb->prefix}forum_user_meta WHERE user_id = %d AND meta_key = %s LIMIT 1", + (int) $user_id, $key + ) ); + return $value !== null ? $value : ''; + } + public static function set_user_meta( $user_id, $key, $value ) { global $wpdb; $wpdb->replace( diff --git a/includes/class-forum-mc-bridge.php b/includes/class-forum-mc-bridge.php new file mode 100644 index 0000000..ae290c3 --- /dev/null +++ b/includes/class-forum-mc-bridge.php @@ -0,0 +1,480 @@ +display_name : 'Unbekannt'; + + // Kontext-Daten sammeln + $title = ''; + $url = ''; + $forum_url = wbf_get_forum_url(); + + switch ( $type ) { + case 'reply': + case 'mention': + $thread = WBF_DB::get_thread( (int) $object_id ); + if ( $thread ) { + $title = $thread->title; + $url = $forum_url . '?forum_thread=' . (int) $thread->id; + } + break; + + case 'message': + $title = 'Neue Privatnachricht'; + $url = $forum_url . '?forum_dm=1'; + break; + } + + // Push an BungeeCord senden + self::push_to_bungee( $mc_uuid, $type, $title, $actor_name, $url, $user_id ); + } + + /** + * Sendet die Benachrichtigung per HTTP POST an den BungeeCord StatusAPI Server. + */ + private static function push_to_bungee( $mc_uuid, $type, $title, $author, $url, $wp_user_id ) { + $api_url = self::get_api_url(); + $secret = self::get_api_secret(); + + $payload = wp_json_encode( [ + 'player_uuid' => $mc_uuid, + 'type' => $type, + 'title' => $title, + 'author' => $author, + 'url' => $url, + 'wp_user_id' => (int) $wp_user_id, + ] ); + + $args = [ + 'method' => 'POST', + 'timeout' => 5, + 'blocking' => false, // Non-blocking — Seite wartet nicht auf Antwort + 'headers' => [ + 'Content-Type' => 'application/json; charset=UTF-8', + 'X-Api-Key' => $secret, + ], + 'body' => $payload, + 'sslverify' => false, // Lokales Netzwerk braucht kein SSL + ]; + + wp_remote_post( $api_url . '/forum/notify', $args ); + } + + // ══════════════════════════════════════════════════════════════════════════ + // ── REST API (für BungeeCord → WordPress) ───────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════ + + public static function register_rest_routes() { + // POST /wp-json/mc-bridge/v1/verify-link + // BungeeCord schickt Token + MC-UUID → WP verifiziert und speichert + register_rest_route( 'mc-bridge/v1', '/verify-link', [ + 'methods' => 'POST', + 'callback' => [ __CLASS__, 'rest_verify_link' ], + 'permission_callback' => '__return_true', + ] ); + + // POST /wp-json/mc-bridge/v1/unlink + // BungeeCord kann Verknüpfung auch von der MC-Seite lösen + register_rest_route( 'mc-bridge/v1', '/unlink', [ + 'methods' => 'POST', + 'callback' => [ __CLASS__, 'rest_unlink' ], + 'permission_callback' => '__return_true', + ] ); + + // GET /wp-json/mc-bridge/v1/status + // Verbindungstest + register_rest_route( 'mc-bridge/v1', '/status', [ + 'methods' => 'GET', + 'callback' => [ __CLASS__, 'rest_status' ], + 'permission_callback' => '__return_true', + ] ); + } + + /** + * REST: Verknüpfung bestätigen. + * BungeeCord sendet: { "token": "...", "mc_uuid": "...", "mc_name": "..." } + */ + public static function rest_verify_link( $request ) { + // Rate Limiting: max 10 Versuche pro IP pro Minute + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $limit_key = 'wbf_mc_link_' . md5($ip); + $attempts = (int) get_transient($limit_key); + if ($attempts >= 10) { + return new WP_REST_Response([ + 'success' => false, + 'error' => 'rate_limited', + 'message' => 'Zu viele Versuche. Bitte warte eine Minute.' + ], 429); + } + set_transient($limit_key, $attempts + 1, 60); + // API-Secret prüfen + $secret = self::get_api_secret(); + if ( ! empty( $secret ) ) { + $provided = $request->get_header( 'X-Api-Key' ); + if ( $provided !== $secret ) { + return new WP_REST_Response( [ 'success' => false, 'error' => 'unauthorized' ], 403 ); + } + } + + $token = sanitize_text_field( $request->get_param( 'token' ) ); + $mc_uuid = sanitize_text_field( $request->get_param( 'mc_uuid' ) ); + $mc_name = sanitize_text_field( $request->get_param( 'mc_name' ) ); + + if ( empty( $token ) || empty( $mc_uuid ) ) { + return new WP_REST_Response( [ 'success' => false, 'error' => 'missing_fields' ], 400 ); + } + + // Token in forum_user_meta suchen + global $wpdb; + $meta_row = $wpdb->get_row( $wpdb->prepare( + "SELECT user_id FROM {$wpdb->prefix}forum_user_meta WHERE meta_key = %s AND meta_value = %s", + self::META_LINK_TOKEN, $token + ) ); + + if ( ! $meta_row ) { + return new WP_REST_Response( [ 'success' => false, 'error' => 'invalid_token' ], 404 ); + } + + $forum_user_id = (int) $meta_row->user_id; + + // Ablauf prüfen + $expiry_meta = WBF_DB::get_user_meta( $forum_user_id ); + $expiry = $expiry_meta[ self::META_LINK_EXPIRY ] ?? '0'; + if ( (int) $expiry < time() ) { + // Token abgelaufen — aufräumen + WBF_DB::set_user_meta( $forum_user_id, self::META_LINK_TOKEN, '' ); + WBF_DB::set_user_meta( $forum_user_id, self::META_LINK_EXPIRY, '' ); + return new WP_REST_Response( [ 'success' => false, 'error' => 'token_expired' ], 410 ); + } + + // Prüfen ob diese MC-UUID bereits mit einem anderen Account verknüpft ist + $existing = $wpdb->get_row( $wpdb->prepare( + "SELECT user_id FROM {$wpdb->prefix}forum_user_meta WHERE meta_key = %s AND meta_value = %s", + self::META_MC_UUID, $mc_uuid + ) ); + if ( $existing && (int) $existing->user_id !== $forum_user_id ) { + return new WP_REST_Response( [ + 'success' => false, + 'error' => 'uuid_already_linked', + 'message' => 'Diese Minecraft-UUID ist bereits mit einem anderen Forum-Account verknüpft.', + ], 409 ); + } + + // Verknüpfung speichern + WBF_DB::set_user_meta( $forum_user_id, self::META_MC_UUID, $mc_uuid ); + WBF_DB::set_user_meta( $forum_user_id, self::META_MC_NAME, $mc_name ); + + // Token aufräumen + WBF_DB::set_user_meta( $forum_user_id, self::META_LINK_TOKEN, '' ); + WBF_DB::set_user_meta( $forum_user_id, self::META_LINK_EXPIRY, '' ); + + // Forum-User-Info für BungeeCord zurückgeben + $forum_user = WBF_DB::get_user( $forum_user_id ); + + return new WP_REST_Response( [ + 'success' => true, + 'forum_user_id' => $forum_user_id, + 'display_name' => $forum_user ? $forum_user->display_name : '', + 'username' => $forum_user ? $forum_user->username : '', + ], 200 ); + } + + /** + * REST: Verknüpfung lösen (von BungeeCord-Seite). + */ + public static function rest_unlink( $request ) { + $secret = self::get_api_secret(); + if ( ! empty( $secret ) ) { + $provided = $request->get_header( 'X-Api-Key' ); + if ( $provided !== $secret ) { + return new WP_REST_Response( [ 'success' => false, 'error' => 'unauthorized' ], 403 ); + } + } + + $mc_uuid = sanitize_text_field( $request->get_param( 'mc_uuid' ) ); + if ( empty( $mc_uuid ) ) { + return new WP_REST_Response( [ 'success' => false, 'error' => 'missing_mc_uuid' ], 400 ); + } + + global $wpdb; + $meta_row = $wpdb->get_row( $wpdb->prepare( + "SELECT user_id FROM {$wpdb->prefix}forum_user_meta WHERE meta_key = %s AND meta_value = %s", + self::META_MC_UUID, $mc_uuid + ) ); + + if ( ! $meta_row ) { + return new WP_REST_Response( [ 'success' => false, 'error' => 'not_linked' ], 404 ); + } + + $forum_user_id = (int) $meta_row->user_id; + WBF_DB::set_user_meta( $forum_user_id, self::META_MC_UUID, '' ); + WBF_DB::set_user_meta( $forum_user_id, self::META_MC_NAME, '' ); + + return new WP_REST_Response( [ 'success' => true ], 200 ); + } + + /** + * REST: Status-Endpoint für Verbindungstest. + */ + public static function rest_status( $request ) { + return new WP_REST_Response( [ + 'success' => true, + 'enabled' => self::is_enabled(), + 'version' => defined( 'WBF_VERSION' ) ? WBF_VERSION : '?', + 'plugin' => 'WP Business Forum', + ], 200 ); + } + + // ══════════════════════════════════════════════════════════════════════════ + // ── AJAX: Token generieren ──────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════ + + /** + * Generiert einen 8-stelligen Verknüpfungs-Token (15 Minuten gültig). + * Der User gibt diesen Token dann ingame mit /forumlink ein. + */ + public static function ajax_generate_token() { + check_ajax_referer( 'wbf_nonce', 'nonce' ); + $user = WBF_Auth::get_current_user(); + if ( ! $user ) { + wp_send_json_error( [ 'message' => 'Nicht eingeloggt.' ] ); + } + + // Prüfen ob bereits verknüpft + $existing_uuid = WBF_DB::get_user_meta_single( $user->id, self::META_MC_UUID ); + if ( ! empty( $existing_uuid ) ) { + $mc_name = WBF_DB::get_user_meta_single( $user->id, self::META_MC_NAME ); + wp_send_json_error( [ + 'message' => 'Dein Account ist bereits mit ' . esc_html( $mc_name ?: $existing_uuid ) . ' verknüpft.', + 'linked' => true, + 'mc_name' => $mc_name, + 'mc_uuid' => $existing_uuid, + ] ); + } + + // Token generieren: 8 Zeichen, alphanumerisch, uppercase + $token = strtoupper( substr( bin2hex( random_bytes( 5 ) ), 0, 8 ) ); + $expiry = time() + ( 15 * 60 ); // 15 Minuten + + WBF_DB::set_user_meta( $user->id, self::META_LINK_TOKEN, $token ); + WBF_DB::set_user_meta( $user->id, self::META_LINK_EXPIRY, (string) $expiry ); + + wp_send_json_success( [ + 'token' => $token, + 'expires_in' => 15, // Minuten + 'command' => '/forumlink ' . $token, + ] ); + } + + /** + * AJAX: Verknüpfung lösen (von der Forum-Seite). + */ + public static function ajax_unlink() { + check_ajax_referer( 'wbf_nonce', 'nonce' ); + $user = WBF_Auth::get_current_user(); + if ( ! $user ) { + wp_send_json_error( [ 'message' => 'Nicht eingeloggt.' ] ); + } + + $mc_uuid = WBF_DB::get_user_meta_single( $user->id, self::META_MC_UUID ); + + // Meta löschen + WBF_DB::set_user_meta( $user->id, self::META_MC_UUID, '' ); + WBF_DB::set_user_meta( $user->id, self::META_MC_NAME, '' ); + WBF_DB::set_user_meta( $user->id, self::META_LINK_TOKEN, '' ); + WBF_DB::set_user_meta( $user->id, self::META_LINK_EXPIRY, '' ); + + // Optional: BungeeCord informieren + if ( ! empty( $mc_uuid ) && self::is_enabled() ) { + $api_url = self::get_api_url(); + $secret = self::get_api_secret(); + if ( ! empty( $api_url ) ) { + wp_remote_post( $api_url . '/forum/unlink', [ + 'method' => 'POST', + 'timeout' => 3, + 'blocking' => false, + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Api-Key' => $secret, + ], + 'body' => wp_json_encode( [ 'mc_uuid' => $mc_uuid ] ), + 'sslverify' => false, + ] ); + } + } + + wp_send_json_success( [ 'message' => 'Minecraft-Verknüpfung wurde aufgehoben.' ] ); + } + + /** + * AJAX: Verknüpfungs-Status prüfen. + * Wird vom Frontend-Polling alle 5 Sekunden nach Token-Generierung aufgerufen. + * Gibt zurück ob der User bereits verknüpft ist (BungeeCord hat verify-link gesendet). + */ + public static function ajax_link_status() { + check_ajax_referer( 'wbf_nonce', 'nonce' ); + $user = WBF_Auth::get_current_user(); + if ( ! $user ) { + wp_send_json_error( [ 'message' => 'Nicht eingeloggt.', 'linked' => false ] ); + return; + } + $mc_uuid = WBF_DB::get_user_meta_single( $user->id, self::META_MC_UUID ); + $mc_name = WBF_DB::get_user_meta_single( $user->id, self::META_MC_NAME ); + if ( ! empty( $mc_uuid ) ) { + wp_send_json_success( [ + 'linked' => true, + 'mc_uuid' => $mc_uuid, + 'mc_name' => $mc_name, + ] ); + } else { + wp_send_json_success( [ 'linked' => false ] ); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════ + + /** + * Gibt die MC-UUID eines Forum-Users zurück (oder leer). + */ + public static function get_mc_uuid( $forum_user_id ) { + return WBF_DB::get_user_meta_single( $forum_user_id, self::META_MC_UUID ); + } + + /** + * Gibt den MC-Namen eines Forum-Users zurück (oder leer). + */ + public static function get_mc_name( $forum_user_id ) { + return WBF_DB::get_user_meta_single( $forum_user_id, self::META_MC_NAME ); + } + + /** + * Prüft ob ein Forum-User mit MC verknüpft ist. + */ + public static function is_linked( $forum_user_id ) { + $uuid = self::get_mc_uuid( $forum_user_id ); + return ! empty( $uuid ); + } + + /** + * HTML-Badge für Profilansicht: Zeigt MC-Verknüpfung an. + */ + public static function profile_badge( $forum_user_id ) { + if ( ! self::is_enabled() ) return ''; + $mc_name = self::get_mc_name( $forum_user_id ); + if ( empty( $mc_name ) ) return ''; + + $name_esc = esc_html( $mc_name ); + $head_url = 'https://mc-heads.net/avatar/' . urlencode( $mc_name ) . '/24'; + return "" + . "\"\"" + . "{$name_esc}" + . ""; + } +} + +// Initialisierung +add_action( 'init', [ 'WBF_MC_Bridge', 'init' ], 20 ); \ No newline at end of file diff --git a/includes/class-forum-shortcodes.php b/includes/class-forum-shortcodes.php index 94b277e..a1f963b 100644 --- a/includes/class-forum-shortcodes.php +++ b/includes/class-forum-shortcodes.php @@ -928,12 +928,19 @@ class WBF_Shortcodes { // Aktiven Tab aus URL lesen (tab=1|2|3), Standard: 1 für eigenes, 2 für fremdes // Tab-ID: numerisch (1–4) oder String-Slug (z.B. 'mc' von der Forum-Bridge) $ptab_raw = $_GET['ptab'] ?? ($is_own ? 1 : 2); + $shop_active = class_exists('WIS_DB'); + $shop_tab_id = 'shop'; + $allowed_tabs = [1,2,3,4]; + if ($is_own && $shop_active) $allowed_tabs[] = $shop_tab_id; $active_tab = ctype_digit( (string) $ptab_raw ) ? (int) $ptab_raw : sanitize_key( $ptab_raw ); - if ( is_int($active_tab) && ! in_array($active_tab, [1,2,3,4]) ) { + if (is_int($active_tab) && !in_array($active_tab, [1,2,3,4])) { $active_tab = $is_own ? 1 : 2; } - // Tab 1, 3, 4 und String-Tabs nur für eigenes Profil (außer Tab 2 = Aktivität) - if ( ! $is_own && $active_tab !== 2 ) $active_tab = 2; + if (!is_int($active_tab) && $active_tab !== $shop_tab_id && $active_tab !== 'mc') { + $active_tab = $is_own ? 1 : 2; + } + // Tab 1, 3, 4, "shop" und String-Tabs nur für eigenes Profil (außer Tab 2 = Aktivität) + if (!$is_own && $active_tab !== 2) $active_tab = 2; ob_start(); ?>
@@ -948,6 +955,21 @@ class WBF_Shortcodes {
+ - - role) >= 80): // Nur Admins/Mods ?> -
- - -
-
diff --git a/includes/class-forum-totp.php b/includes/class-forum-totp.php new file mode 100644 index 0000000..001d314 --- /dev/null +++ b/includes/class-forum-totp.php @@ -0,0 +1,179 @@ +delete( + "{$wpdb->prefix}forum_user_meta", + [ 'user_id' => $uid, 'meta_key' => self::META_SECRET ], + [ '%d', '%s' ] + ); + $wpdb->delete( + "{$wpdb->prefix}forum_user_meta", + [ 'user_id' => $uid, 'meta_key' => self::META_PENDING ], + [ '%d', '%s' ] + ); + } + + // ── Base32 ──────────────────────────────────────────────────────────────── + + private static $b32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + + public static function base32_encode( $input ) { + $output = ''; + $buf = 0; + $buf_bits = 0; + for ( $i = 0, $len = strlen($input); $i < $len; $i++ ) { + $buf = ( $buf << 8 ) | ord( $input[$i] ); + $buf_bits += 8; + while ( $buf_bits >= 5 ) { + $buf_bits -= 5; + $output .= self::$b32[ ( $buf >> $buf_bits ) & 0x1f ]; + } + } + if ( $buf_bits > 0 ) { + $output .= self::$b32[ ( $buf << ( 5 - $buf_bits ) ) & 0x1f ]; + } + // Padding to multiple of 8 + while ( strlen($output) % 8 !== 0 ) $output .= '='; + return $output; + } + + public static function base32_decode( $input ) { + // Leerzeichen & Padding entfernen, Uppercase + $input = strtoupper( preg_replace( '/\s+/', '', $input ) ); + $input = rtrim( $input, '=' ); + $map = array_flip( str_split( self::$b32 ) ); + + $output = ''; + $buf = 0; + $bits = 0; + for ( $i = 0, $len = strlen($input); $i < $len; $i++ ) { + if ( ! isset( $map[ $input[$i] ] ) ) continue; // ungültiges Zeichen ignorieren + $buf = ( $buf << 5 ) | $map[ $input[$i] ]; + $bits += 5; + if ( $bits >= 8 ) { + $bits -= 8; + $output .= chr( ( $buf >> $bits ) & 0xff ); + } + } + return $output; + } +} \ No newline at end of file diff --git a/includes/forum-statusapi.php b/includes/forum-statusapi.php new file mode 100644 index 0000000..483db54 --- /dev/null +++ b/includes/forum-statusapi.php @@ -0,0 +1,32 @@ + $player, + 'message' => $message + ]; + $args = [ + 'body' => json_encode($data), + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-API-Key' => $api_secret, + ], + 'timeout' => 2, + 'data_format' => 'body', + ]; + wp_remote_post($url, $args); +}