Update from Git Manager GUI

This commit is contained in:
2026-03-30 20:41:51 +02:00
parent 56f8c01b52
commit f4d0ec73c0
7 changed files with 1384 additions and 112 deletions

View File

@@ -2,72 +2,12 @@
if ( ! defined( 'ABSPATH' ) ) exit; if ( ! defined( 'ABSPATH' ) ) exit;
class WBF_Ajax { 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() { public static function init() {
$actions = [ $actions = [
'wbf_login', 'wbf_register', 'wbf_logout', 'wbf_login', 'wbf_register', 'wbf_logout',
'wbf_new_thread', 'wbf_new_post', 'wbf_toggle_like', '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_forgot_password', 'wbf_reset_password', 'wbf_load_more_messages',
'wbf_create_invite', 'wbf_delete_invite', 'wbf_create_invite', 'wbf_delete_invite',
'wbf_toggle_subscribe', 'wbf_restore_content', 'wbf_toggle_profile_visibility', 'wbf_toggle_subscribe', 'wbf_restore_content', 'wbf_toggle_profile_visibility',
@@ -84,8 +24,10 @@ class WBF_Ajax {
'wbf_save_discord', 'wbf_save_discord',
'wbf_discord_send_code', 'wbf_discord_send_code',
'wbf_discord_verify_code', 'wbf_discord_verify_code',
'wbf_manual_discord_sync', 'wbf_2fa_setup_begin',
'wbf_discord_sync_user', 'wbf_2fa_setup_verify',
'wbf_2fa_disable',
'wbf_2fa_verify_login',
]; ];
foreach ($actions as $action) { foreach ($actions as $action) {
add_action('wp_ajax_nopriv_' . $action, [__CLASS__, str_replace('wbf_','handle_',$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 // Login braucht keinen Nonce — Credentials sind die Authentifizierung
$result = WBF_Auth::login( $result = WBF_Auth::login(
sanitize_text_field($_POST['username'] ?? ''), sanitize_text_field($_POST['username'] ?? ''),
$_POST['password'] ?? '' $_POST['password'] ?? '',
! empty($_POST['remember_me'])
); );
if ($result['success']) { if ($result['success']) {
// Erfolgreicher Login: Fehlzähler löschen // Erfolgreicher Login: Fehlzähler löschen
@@ -131,6 +74,9 @@ class WBF_Ajax {
WBF_Auth::set_remember_cookie($u->id); 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]); 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 { } else {
// Fehlversuch zählen — außer bei gesperrten Konten (kein Passwortfehler) // Fehlversuch zählen — außer bei gesperrten Konten (kein Passwortfehler)
if ( empty($result['banned']) ) { if ( empty($result['banned']) ) {
@@ -202,7 +148,10 @@ class WBF_Ajax {
} }
public static function handle_logout() { 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(); WBF_Auth::logout();
wp_send_json_success(['message' => 'logged_out']); wp_send_json_success(['message' => 'logged_out']);
} }
@@ -250,6 +199,10 @@ class WBF_Ajax {
'content' => WBF_DB::apply_word_filter($content), 'content' => WBF_DB::apply_word_filter($content),
'prefix_id' => $prefix_id, '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 // Tags speichern
$raw_tags = sanitize_text_field( $_POST['tags'] ?? '' ); $raw_tags = sanitize_text_field( $_POST['tags'] ?? '' );
@@ -572,25 +525,21 @@ class WBF_Ajax {
wp_send_json_success(['avatar_url'=>$url]); wp_send_json_success(['avatar_url'=>$url]);
} }
// ── Banner Upload ────────────────────────────────────────────────────────
public static function handle_upload_banner() { public static function handle_upload_banner() {
self::verify(); self::verify();
$user = WBF_Auth::get_current_user(); $user = WBF_Auth::get_current_user();
if (!$user) wp_send_json_error(['message'=>'Nicht eingeloggt.']); if ( ! $user ) wp_send_json_error(['message' => 'Nicht eingeloggt.']);
if (empty($_FILES['banner'])) wp_send_json_error(['message'=>'Keine Datei.']); if ( empty($_FILES['banner']) ) wp_send_json_error(['message' => 'Keine Datei.']);
$allowed_types = ['image/jpeg','image/png','image/gif','image/webp']; $allowed_types = ['image/jpeg','image/png','image/gif','image/webp'];
// Max 4 MB für Banner (größer als Avatar) if ( $_FILES['banner']['size'] > 3 * 1024 * 1024 ) {
if ( $_FILES['banner']['size'] > 4 * 1024 * 1024 ) { wp_send_json_error(['message' => 'Maximale Dateigröße: 3 MB.']);
wp_send_json_error(['message'=>'Maximale Dateigröße: 4 MB.']);
} }
// Server-seitige MIME-Typ-Prüfung
$tmp = $_FILES['banner']['tmp_name'] ?? ''; $tmp = $_FILES['banner']['tmp_name'] ?? '';
if ( ! $tmp || ! is_uploaded_file( $tmp ) ) { 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') ) { if ( function_exists('finfo_open') ) {
$finfo = finfo_open( FILEINFO_MIME_TYPE ); $finfo = finfo_open( FILEINFO_MIME_TYPE );
@@ -603,11 +552,11 @@ class WBF_Ajax {
IMAGETYPE_GIF => 'image/gif', IMAGETYPE_GIF => 'image/gif',
IMAGETYPE_WEBP => 'image/webp', IMAGETYPE_WEBP => 'image/webp',
]; ];
$et = @exif_imagetype( $tmp ); $et = @exif_imagetype( $tmp );
$real_mime = $et_map[$et] ?? ''; $real_mime = $et_map[$et] ?? '';
} }
if ( ! in_array( $real_mime, $allowed_types, true ) ) { 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'; require_once ABSPATH . 'wp-admin/includes/image.php';
@@ -615,11 +564,11 @@ class WBF_Ajax {
require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/media.php';
$id = media_handle_upload('banner', 0); $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); $url = wp_get_attachment_url($id);
WBF_DB::update_user($user->id, ['banner_url'=>$url]); WBF_DB::update_user($user->id, ['banner_url' => $url]);
wp_send_json_success(['banner_url'=>$url]); wp_send_json_success(['banner_url' => $url]);
} }
// ── Report ──────────────────────────────────────────────────────────────── // ── Report ────────────────────────────────────────────────────────────────
@@ -1738,6 +1687,143 @@ class WBF_Ajax {
return ( ! is_wp_error($msg_res) && wp_remote_retrieve_response_code($msg_res) === 200 ); 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' ] ); add_action( 'init', [ 'WBF_Ajax', 'init' ] );

View File

@@ -3,7 +3,7 @@ if ( ! defined( 'ABSPATH' ) ) exit;
class WBF_Auth { class WBF_Auth {
const SESSION_KEY = 'wbf_forum_user'; const SESSION_KEY = 'wbf_forum_user';
public static function init() { public static function init() {
// PHP 8.3: session_start() nach gesendeten Headers erzeugt E_WARNING, // 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. // Lösung: headers_sent() prüfen + session_start() mit Cookie-Optionen aufrufen.
if ( ! session_id() ) { if ( ! session_id() ) {
if ( headers_sent() ) { 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; return;
} }
$session_opts = [ $session_opts = [
@@ -20,7 +18,6 @@ class WBF_Auth {
'cookie_samesite' => 'Lax', 'cookie_samesite' => 'Lax',
'use_strict_mode' => true, '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' ) ) { if ( is_ssl() || ( ! empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ) ) {
$session_opts['cookie_secure'] = true; $session_opts['cookie_secure'] = true;
} }
@@ -50,7 +47,7 @@ class WBF_Auth {
return WBF_DB::get_user( (int) $_SESSION[ self::SESSION_KEY ] ); 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(); self::init();
$user = WBF_DB::get_user_by( 'username', $username_or_email ); $user = WBF_DB::get_user_by( 'username', $username_or_email );
if ( ! $user ) { if ( ! $user ) {
@@ -60,6 +57,19 @@ class WBF_Auth {
if ( ! password_verify( $password, $user->password ) ) { if ( ! password_verify( $password, $user->password ) ) {
return array( 'success' => false, 'message' => 'Falsches Passwort.' ); 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 ) { if ( WBF_Roles::level($user->role) < 0 ) {
// Zeitlich begrenzte Sperre prüfen — automatisch aufheben wenn abgelaufen // Zeitlich begrenzte Sperre prüfen — automatisch aufheben wenn abgelaufen
if ( ! empty($user->ban_until) && strtotime($user->ban_until) <= time() ) { if ( ! empty($user->ban_until) && strtotime($user->ban_until) <= time() ) {
@@ -70,22 +80,20 @@ class WBF_Auth {
'ban_until' => null, 'ban_until' => null,
'pre_ban_role' => '', 'pre_ban_role' => '',
]); ]);
// Frisch laden und einloggen
$user = WBF_DB::get_user( $user->id ); $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; $_SESSION[ self::SESSION_KEY ] = $user->id;
WBF_DB::touch_last_active( $user->id ); WBF_DB::touch_last_active( $user->id );
return array( 'success' => true, 'user' => $user ); return array( 'success' => true, 'user' => $user );
} }
$reason = !empty($user->ban_reason) ? $user->ban_reason : 'Dein Konto wurde gesperrt.'; $reason = !empty($user->ban_reason) ? $user->ban_reason : 'Dein Konto wurde gesperrt.';
// Zeitstempel anhängen wenn temporäre Sperre
if ( ! empty($user->ban_until) ) { if ( ! empty($user->ban_until) ) {
$until_fmt = date_i18n( 'd.m.Y \u\m H:i \U\h\r', strtotime($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 . ')'; $reason .= ' (Gesperrt bis: ' . $until_fmt . ')';
} }
return array( 'success' => false, 'banned' => true, 'message' => $reason ); 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; $_SESSION[ self::SESSION_KEY ] = $user->id;
WBF_DB::touch_last_active( $user->id ); WBF_DB::touch_last_active( $user->id );
return array( 'success' => true, 'user' => $user ); return array( 'success' => true, 'user' => $user );
@@ -115,7 +123,7 @@ class WBF_Auth {
'avatar_url' => $avatar, '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; $_SESSION[ self::SESSION_KEY ] = $id;
return array('success'=>true,'user'=>WBF_DB::get_user($id)); return array('success'=>true,'user'=>WBF_DB::get_user($id));
} }
@@ -124,10 +132,14 @@ class WBF_Auth {
self::init(); self::init();
$user_id = $_SESSION[ self::SESSION_KEY ] ?? 0; $user_id = $_SESSION[ self::SESSION_KEY ] ?? 0;
unset( $_SESSION[ self::SESSION_KEY ] ); 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 ) { if ( $user_id ) {
WBF_DB::delete_remember_token( (int)$user_id ); WBF_DB::delete_remember_token( (int)$user_id );
} }
// Remove cookie
if ( isset($_COOKIE['wbf_remember']) ) { if ( isset($_COOKIE['wbf_remember']) ) {
setcookie( 'wbf_remember', '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); setcookie( 'wbf_remember', '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
} }

View File

@@ -151,7 +151,6 @@ class WBF_DB {
dbDelta( $sql_threads ); dbDelta( $sql_threads );
dbDelta( $sql_posts ); dbDelta( $sql_posts );
dbDelta( $sql_likes ); dbDelta( $sql_likes );
dbDelta( $sql_reports );
dbDelta( $sql_tags ); dbDelta( $sql_tags );
dbDelta( $sql_thread_tags ); dbDelta( $sql_thread_tags );
dbDelta( $sql_messages ); dbDelta( $sql_messages );
@@ -201,7 +200,6 @@ class WBF_DB {
) $charset;"; ) $charset;";
// Ensure reports + notifications tables exist on existing installs // Ensure reports + notifications tables exist on existing installs
dbDelta( $sql_reports );
dbDelta( $sql_notifications ); dbDelta( $sql_notifications );
// Einladungs-Tabelle // 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_posts", ['thread_id' => $id]);
$wpdb->delete("{$wpdb->prefix}forum_threads", ['id' => $id]); $wpdb->delete("{$wpdb->prefix}forum_threads", ['id' => $id]);
// Zähler anpassen
if ( $thread->status !== 'archived' ) { 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, 'object_id' => $object_id,
'actor_id' => $actor_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 ) { public static function get_notifications( $user_id, $limit = 20 ) {
@@ -1253,12 +1267,13 @@ class WBF_DB {
public static function create_remember_token( $user_id ) { public static function create_remember_token( $user_id ) {
global $wpdb; global $wpdb;
$token = bin2hex( random_bytes(32) ); $token = bin2hex( random_bytes(32) );
$token_hash = hash('sha256', $token);
$expires = date('Y-m-d H:i:s', strtotime('+30 days')); $expires = date('Y-m-d H:i:s', strtotime('+30 days'));
// Delete existing tokens for this user first // Delete existing tokens for this user first
$wpdb->delete( "{$wpdb->prefix}forum_remember_tokens", ['user_id' => $user_id] ); $wpdb->delete( "{$wpdb->prefix}forum_remember_tokens", ['user_id' => $user_id] );
$wpdb->insert( "{$wpdb->prefix}forum_remember_tokens", [ $wpdb->insert( "{$wpdb->prefix}forum_remember_tokens", [
'user_id' => $user_id, 'user_id' => $user_id,
'token' => $token, 'token' => $token_hash,
'expires_at' => $expires, 'expires_at' => $expires,
] ); ] );
return $token; return $token;
@@ -1268,10 +1283,11 @@ class WBF_DB {
global $wpdb; global $wpdb;
$table = "{$wpdb->prefix}forum_remember_tokens"; $table = "{$wpdb->prefix}forum_remember_tokens";
if ( $wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table ) return null; 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( return $wpdb->get_row( $wpdb->prepare(
"SELECT user_id FROM {$wpdb->prefix}forum_remember_tokens "SELECT user_id FROM {$wpdb->prefix}forum_remember_tokens
WHERE token=%s AND expires_at > NOW()", 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 ) { public static function soft_delete_post( $post_id ) {
global $wpdb; global $wpdb;
// Soft-Delete setzen
$wpdb->update( $wpdb->update(
"{$wpdb->prefix}forum_posts", "{$wpdb->prefix}forum_posts",
['deleted_at' => current_time('mysql')], ['deleted_at' => current_time('mysql')],
['id' => (int)$post_id] ['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 ) { public static function restore_thread( $thread_id ) {
@@ -1587,6 +1617,18 @@ class WBF_DB {
return $out; 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 ) { public static function set_user_meta( $user_id, $key, $value ) {
global $wpdb; global $wpdb;
$wpdb->replace( $wpdb->replace(

View File

@@ -0,0 +1,480 @@
<?php
/**
* WBF_MC_Bridge — Minecraft ↔ Forum Verknüpfung & Ingame-Benachrichtigungen
*
* Dieses Modul verbindet das WP Business Forum mit dem BungeeCord StatusAPI Plugin.
*
* Features:
* - Account-Verknüpfung: Forum-User ↔ MC-UUID (über Token-System)
* - Push-Benachrichtigungen: Neue Antwort/Erwähnung/PN → Ingame-Nachricht
* - REST API Endpoints für die BungeeCord-Seite
*
* Einbindung in wp-business-forum.php:
* require_once WBF_PATH . 'includes/class-forum-mc-bridge.php';
*
* Konfiguration in WBF-Einstellungen (Admin → Forum → Einstellungen):
* mc_bridge_enabled = true/false
* mc_bridge_api_url = http://server-ip:9191 (StatusAPI URL)
* mc_bridge_api_secret = Shared Secret für API-Authentifizierung
*/
if ( ! defined( 'ABSPATH' ) ) exit;
class WBF_MC_Bridge {
/** Meta-Keys in forum_user_meta */
const META_MC_UUID = 'mc_uuid';
const META_MC_NAME = 'mc_name';
const META_LINK_TOKEN = 'mc_link_token';
const META_LINK_EXPIRY = 'mc_link_token_expires';
/**
* Hooks registrieren — wird beim Plugin-Laden aufgerufen.
*/
public static function init() {
// Hook: Wird in der modifizierten WBF_DB::create_notification() gefeuert
add_action( 'wbf_notification_created', [ __CLASS__, 'on_notification' ], 10, 4 );
// REST API Endpoints für BungeeCord
add_action( 'rest_api_init', [ __CLASS__, 'register_rest_routes' ] );
// AJAX: Token generieren (für eingeloggte Forum-User)
add_action( 'wp_ajax_wbf_mc_generate_token', [ __CLASS__, 'ajax_generate_token' ] );
add_action( 'wp_ajax_nopriv_wbf_mc_generate_token', [ __CLASS__, 'ajax_generate_token' ] );
// AJAX: Verknüpfung lösen
add_action( 'wp_ajax_wbf_mc_unlink', [ __CLASS__, 'ajax_unlink' ] );
add_action( 'wp_ajax_nopriv_wbf_mc_unlink', [ __CLASS__, 'ajax_unlink' ] );
// AJAX: Link-Status prüfen (Polling im Profil nach Token-Generierung)
add_action( 'wp_ajax_wbf_mc_link_status', [ __CLASS__, 'ajax_link_status' ] );
add_action( 'wp_ajax_nopriv_wbf_mc_link_status', [ __CLASS__, 'ajax_link_status' ] );
}
// ══════════════════════════════════════════════════════════════════════════
// ── Einstellungen ─────────────────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════════════════
/**
* Prüft ob die MC-Bridge aktiviert ist.
*/
public static function is_enabled() {
$s = function_exists( 'wbf_get_settings' ) ? wbf_get_settings() : [];
return ! empty( $s['mc_bridge_enabled'] );
}
/**
* Gibt die StatusAPI-URL zurück (z.B. http://192.168.1.100:9191).
*/
private static function get_api_url() {
$s = function_exists( 'wbf_get_settings' ) ? wbf_get_settings() : [];
return rtrim( $s['mc_bridge_api_url'] ?? '', '/' );
}
/**
* Gibt das Shared Secret zurück.
*/
private static function get_api_secret() {
$s = function_exists( 'wbf_get_settings' ) ? wbf_get_settings() : [];
return $s['mc_bridge_api_secret'] ?? '';
}
// ══════════════════════════════════════════════════════════════════════════
// ── Notification Hook ─────────────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════════════════
/**
* Wird aufgerufen wenn eine Forum-Notification erstellt wird.
* Prüft ob der Empfänger eine MC-UUID hat und pusht die Nachricht an BungeeCord.
*
* @param int $user_id Forum-User ID des Empfängers
* @param string $type Typ: 'reply', 'mention', 'message'
* @param int $object_id Thread-ID (bei reply/mention) oder Message-ID (bei message)
* @param int $actor_id Forum-User ID des Auslösers
*/
public static function on_notification( $user_id, $type, $object_id, $actor_id ) {
if ( ! self::is_enabled() ) return;
$api_url = self::get_api_url();
if ( empty( $api_url ) ) return;
// MC-UUID des Empfängers prüfen
$mc_uuid = WBF_DB::get_user_meta_single( $user_id, self::META_MC_UUID );
if ( empty( $mc_uuid ) ) return;
// Actor-Info laden
$actor = WBF_DB::get_user( (int) $actor_id );
$actor_name = $actor ? $actor->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 <token> 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 "<span class=\"wbf-mc-badge\" title=\"Minecraft: {$name_esc}\">"
. "<img src=\"{$head_url}\" alt=\"\" width=\"16\" height=\"16\" style=\"border-radius:2px;vertical-align:middle;margin-right:4px\">"
. "<span style=\"color:#55ff55\">{$name_esc}</span>"
. "</span>";
}
}
// Initialisierung
add_action( 'init', [ 'WBF_MC_Bridge', 'init' ], 20 );

View File

@@ -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 // Aktiven Tab aus URL lesen (tab=1|2|3), Standard: 1 für eigenes, 2 für fremdes
// Tab-ID: numerisch (14) oder String-Slug (z.B. 'mc' von der Forum-Bridge) // Tab-ID: numerisch (14) oder String-Slug (z.B. 'mc' von der Forum-Bridge)
$ptab_raw = $_GET['ptab'] ?? ($is_own ? 1 : 2); $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 ); $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; $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_int($active_tab) && $active_tab !== $shop_tab_id && $active_tab !== 'mc') {
if ( ! $is_own && $active_tab !== 2 ) $active_tab = 2; $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(); ?> ob_start(); ?>
<div class="wbf-wrap"> <div class="wbf-wrap">
@@ -948,6 +955,21 @@ class WBF_Shortcodes {
<!-- ── SIDEBAR ─────────────────────────────────────────── --> <!-- ── SIDEBAR ─────────────────────────────────────────── -->
<aside class="wbf-profile-sidebar"> <aside class="wbf-profile-sidebar">
<!-- Banner -->
<div class="wbf-profile-banner" id="wbfProfileBannerWrap">
<?php if ( ! empty($profile->banner_url) ) : ?>
<img src="<?php echo esc_url($profile->banner_url); ?>"
alt="" id="wbfProfileBanner" class="wbf-profile-banner__img">
<?php else : ?>
<div class="wbf-profile-banner__placeholder"></div>
<?php endif; ?>
<?php if ($is_own) : ?>
<label class="wbf-banner-upload-btn" title="Banner ändern">
<i class="fas fa-image"></i>
<input type="file" id="wbfBannerFile" accept="image/*" style="display:none">
</label>
<?php endif; ?>
</div>
<div class="wbf-profile-sidebar__avatar-wrap"> <div class="wbf-profile-sidebar__avatar-wrap">
<img src="<?php echo esc_url($profile->avatar_url); ?>" <img src="<?php echo esc_url($profile->avatar_url); ?>"
alt="<?php echo esc_attr($profile->display_name); ?>" alt="<?php echo esc_attr($profile->display_name); ?>"
@@ -994,13 +1016,13 @@ class WBF_Shortcodes {
<?php if (!empty($profile->bio)): ?> <?php if (!empty($profile->bio)): ?>
<div class="wbf-profile-sidebar__section"> <div class="wbf-profile-sidebar__section">
<span class="wbf-profile-sidebar__section-label"><i class="fas fa-align-left"></i> Bio</span> <span class="wbf-profile-sidebar__section-label"><i class="fas fa-align-left"></i> Bio</span>
<p><?php echo WBF_BBCode::render($profile->bio); ?></p> <div class="wbf-profile-sidebar__bio-text"><?php echo WBF_BBCode::render($profile->bio); ?></div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($profile->signature)): ?> <?php if (!empty($profile->signature)): ?>
<div class="wbf-profile-sidebar__section"> <div class="wbf-profile-sidebar__section">
<span class="wbf-profile-sidebar__section-label"><i class="fas fa-pen-nib"></i> Signatur</span> <span class="wbf-profile-sidebar__section-label"><i class="fas fa-pen-nib"></i> Signatur</span>
<p class="wbf-profile-sidebar__sig"><?php echo WBF_BBCode::render($profile->signature); ?></p> <div class="wbf-profile-sidebar__sig"><?php echo WBF_BBCode::render($profile->signature); ?></div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Öffentliche Custom Fields — nach Kategorie gruppiert --> <!-- Öffentliche Custom Fields — nach Kategorie gruppiert -->
@@ -1113,6 +1135,12 @@ class WBF_Shortcodes {
class="wbf-profile-tab<?php echo $active_tab===4?' active':''; ?>"> class="wbf-profile-tab<?php echo $active_tab===4?' active':''; ?>">
<i class="fas fa-lock"></i> Sicherheit <i class="fas fa-lock"></i> Sicherheit
</a> </a>
<?php if ($shop_active): ?>
<a href="?forum_profile=<?php echo (int)$profile->id; ?>&ptab=shop"
class="wbf-profile-tab<?php echo $active_tab==='shop'?' active':''; ?>">
<i class="fas fa-shopping-cart"></i> Käufe
</a>
<?php endif; ?>
<?php <?php
// „Verbindungen" Tab — immer sichtbar (Discord eingebaut, MC optional) // „Verbindungen" Tab — immer sichtbar (Discord eingebaut, MC optional)
$wbf_has_connections = true; $wbf_has_connections = true;
@@ -1124,6 +1152,116 @@ class WBF_Shortcodes {
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- ══════════════════════════════════════════════════
TAB SHOP — Käufe (Shop-Plugin)
══════════════════════════════════════════════════ -->
<?php if ($is_own && $active_tab === 'shop' && $shop_active): ?>
<div class="wbf-profile-card">
<div class="wbf-profile-card__header">
<i class="fas fa-shopping-cart"></i> Deine Käufe
</div>
<div class="wbf-profile-card__body">
<?php
$orders = [];
if (class_exists('WIS_DB')) {
global $wpdb;
$username = $profile->username;
$orders = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}wis_orders WHERE player_name = %s ORDER BY created_at DESC",
$username
));
}
?>
<?php if (empty($orders)): ?>
<p class="wbf-profile-empty">Du hast noch keine Käufe getätigt.</p>
<?php else: ?>
<div class="wbf-shop-orders-list">
<table class="wbf-shop-orders-table">
<thead>
<tr>
<th style="text-align:left">Datum</th>
<th style="text-align:center">Anzahl</th>
<th style="text-align:right">Gesamtpreis</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($orders as $i => $order):
$is_cancelled = strtolower($order->status) === 'cancelled' || strtolower($order->status) === 'storniert';
$row_class = $is_cancelled ? 'wbf-shop-order-cancelled' : '';
?>
<tr class="wbf-shop-order-row <?php echo $row_class; ?>" data-idx="<?php echo $i; ?>">
<td><?php echo date_i18n('d.m.Y H:i', strtotime($order->created_at)); ?></td>
<td style="text-align:center"><?php echo (int)$order->quantity; ?></td>
<td style="text-align:right"><?php echo number_format($order->price * $order->quantity); ?> <?php echo esc_html(get_option('wis_currency_name', 'Coins')); ?></td>
<td style="text-align:center">
<button class="wbf-btn wbf-btn--sm wbf-shop-order-toggle" data-idx="<?php echo $i; ?>"><i class="fas fa-chevron-down"></i></button>
</td>
</tr>
<tr class="wbf-shop-order-details" id="wbf-shop-order-details-<?php echo $i; ?>" style="display:none">
<td colspan="4">
<div class="wbf-shop-order-details-inner">
<?php /* Artikel-Zeile entfernt, da Item-Liste folgt */ ?>
<strong>Einzelpreis:</strong> <?php echo number_format($order->price); ?> <?php echo esc_html(get_option('wis_currency_name', 'Coins')); ?><br>
<strong>Status:</strong> <?php echo esc_html(ucfirst($order->status)); ?><br>
<?php if (!empty($order->server)): ?><strong>Server:</strong> <?php echo esc_html($order->server); ?><br><?php endif; ?>
<?php
// Antwort als JSON-Items/Coupon anzeigen
$response = $order->response;
$decoded = null;
if (!empty($response)) {
$decoded = json_decode($response, true);
}
if (is_array($decoded) && isset($decoded['items'])) {
echo '<strong>Gekaufte Items:</strong><ul style="margin:.3em 0 .7em 1.2em">';
foreach ($decoded['items'] as $item) {
$item_id = isset($item['id']) ? $item['id'] : '';
$item_id = preg_replace('/^minecraft:/', '', $item_id);
$amount = isset($item['amount']) ? (int)$item['amount'] : 1;
echo '<li><span style="color:var(--c-primary)">' . esc_html($item_id) . '</span> <span style="color:var(--c-muted)">x' . $amount . '</span></li>';
}
echo '</ul>';
if (isset($decoded['coupon']['code'])) {
$c = $decoded['coupon'];
echo '<div style="margin:.2em 0 .5em 0"><strong>Coupon:</strong> <span style="color:var(--c-success)">' . esc_html($c['code']) . '</span>';
if (isset($c['discount'])) echo ' <span style="color:var(--c-muted)">(' . intval($c['discount']) . '% Rabatt)</span>';
echo '</div>';
}
} elseif (!empty($response)) {
echo '<strong>Antwort:</strong> ' . esc_html($response) . '<br>';
}
?>
<span style="font-size:.85em;color:var(--c-muted)">Bestell-ID: <?php echo (int)$order->id; ?></span>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.wbf-shop-order-toggle').forEach(function(btn) {
btn.addEventListener('click', function() {
var idx = btn.getAttribute('data-idx');
var details = document.getElementById('wbf-shop-order-details-' + idx);
if (details.style.display === 'none') {
details.style.display = '';
btn.querySelector('i').classList.remove('fa-chevron-down');
btn.querySelector('i').classList.add('fa-chevron-up');
} else {
details.style.display = 'none';
btn.querySelector('i').classList.remove('fa-chevron-up');
btn.querySelector('i').classList.add('fa-chevron-down');
}
});
});
});
</script>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- ══════════════════════════════════════════════════ <!-- ══════════════════════════════════════════════════
TAB 1 — Profil bearbeiten + Weitere Profilangaben TAB 1 — Profil bearbeiten + Weitere Profilangaben
@@ -1537,6 +1675,122 @@ class WBF_Shortcodes {
</div> </div>
</div> </div>
<!-- ══════════════════════════════════════════════════
2FA — Zwei-Faktor-Authentifizierung (TOTP)
══════════════════════════════════════════════════ -->
<?php if ( class_exists('WBF_TOTP') ) :
$wbf_2fa_active = WBF_TOTP::is_enabled_for($current->id);
?>
<div class="wbf-profile-card wbf-2fa-card" id="wbf2faCard">
<div class="wbf-profile-card__header" style="background:rgba(234,179,8,.07);border-bottom-color:rgba(234,179,8,.2)">
<i class="fas fa-shield-halved" style="color:#eab308"></i>
Zwei-Faktor-Authentifizierung (2FA)
<?php if ( $wbf_2fa_active ): ?>
<span class="wbf-2fa-badge wbf-2fa-badge--on"><i class="fas fa-check-circle"></i> Aktiv</span>
<?php else: ?>
<span class="wbf-2fa-badge wbf-2fa-badge--off"><i class="fas fa-circle-xmark"></i> Inaktiv</span>
<?php endif; ?>
</div>
<div class="wbf-profile-card__body">
<!-- ── 2FA bereits aktiv: Deaktivierungs-Formular ── -->
<?php if ( $wbf_2fa_active ): ?>
<div id="wbf2faActive">
<p style="color:var(--c-muted);font-size:.85rem;margin-bottom:1rem">
<i class="fas fa-info-circle"></i>
Dein Account ist mit einem Authenticator gesichert.
Zum Deaktivieren Passwort und aktuellen Code eingeben.
</p>
<div class="wbf-profile-edit-grid">
<div class="wbf-form-row">
<label>Aktuelles Passwort</label>
<input type="password" id="wbf2faDisablePw" placeholder="••••••" autocomplete="current-password">
</div>
<div class="wbf-form-row">
<label>Authenticator-Code</label>
<input type="text" id="wbf2faDisableCode" placeholder="123456"
maxlength="6" inputmode="numeric" autocomplete="one-time-code"
style="letter-spacing:.2em;font-size:1.15rem;font-family:monospace">
</div>
</div>
<div class="wbf-profile-card__footer">
<button class="wbf-btn" id="wbf2faDisableBtn"
style="background:rgba(220,38,38,.1);color:#dc2626;border-color:rgba(220,38,38,.3)">
<i class="fas fa-shield-xmark"></i> 2FA deaktivieren
</button>
<span class="wbf-msg" id="wbf2faDisableMsg"></span>
</div>
</div>
<?php else: ?>
<!-- ── 2FA noch nicht aktiv: Setup-Wizard ── -->
<div id="wbf2faInactive">
<p style="color:var(--c-muted);font-size:.85rem;margin-bottom:1rem">
<i class="fas fa-info-circle"></i>
Schütze deinen Account zusätzlich mit einer Authenticator-App
(Google Authenticator, Aegis, Bitwarden, Authy, 2FAS…).
</p>
<button class="wbf-btn wbf-btn--primary" id="wbf2faStartBtn">
<i class="fas fa-shield-halved"></i> 2FA einrichten
</button>
</div>
<!-- Schritt 1: QR-Code scannen -->
<div id="wbf2faStep1" style="display:none">
<p style="font-size:.85rem;color:var(--c-muted);margin-bottom:.75rem">
<strong>Schritt 1:</strong> Scanne diesen QR-Code mit deiner Authenticator-App.
</p>
<div id="wbf2faQr" style="display:inline-block;padding:10px;background:#fff;border-radius:8px;margin-bottom:.75rem"></div>
<p style="font-size:.8rem;color:var(--c-muted);margin-bottom:.5rem">
Kein QR-Scanner? Gib diesen Code manuell ein:
</p>
<code id="wbf2faSecret" style="font-size:.9rem;letter-spacing:.1em;background:var(--c-bg-2);padding:4px 10px;border-radius:4px;user-select:all"></code>
<div class="wbf-profile-card__footer" style="margin-top:1rem">
<button class="wbf-btn wbf-btn--primary" id="wbf2faToStep2">
Weiter <i class="fas fa-arrow-right"></i>
</button>
</div>
</div>
<!-- Schritt 2: Code bestätigen -->
<div id="wbf2faStep2" style="display:none">
<p style="font-size:.85rem;color:var(--c-muted);margin-bottom:.75rem">
<strong>Schritt 2:</strong> Gib den 6-stelligen Code aus deiner App ein.
</p>
<div class="wbf-form-row" style="max-width:220px">
<label>Bestätigungs-Code</label>
<input type="text" id="wbf2faVerifyCode" placeholder="123456"
maxlength="6" inputmode="numeric" autocomplete="one-time-code"
style="letter-spacing:.25em;font-size:1.3rem;text-align:center;font-family:monospace">
</div>
<div class="wbf-profile-card__footer">
<button class="wbf-btn" id="wbf2faBackBtn" style="opacity:.7">
<i class="fas fa-arrow-left"></i> Zurück
</button>
<button class="wbf-btn wbf-btn--primary" id="wbf2faVerifyBtn">
<i class="fas fa-check"></i> Bestätigen &amp; aktivieren
</button>
<span class="wbf-msg" id="wbf2faVerifyMsg"></span>
</div>
</div>
<!-- Schritt 3: Erfolg -->
<div id="wbf2faStep3" style="display:none;text-align:center;padding:1.5rem 0">
<div style="font-size:2.5rem;margin-bottom:.5rem">🔒</div>
<strong style="font-size:1rem;color:var(--c-text)">2FA erfolgreich aktiviert!</strong>
<p style="font-size:.85rem;color:var(--c-muted);margin:.5rem 0 0">
Ab jetzt wird beim Login ein Code aus deiner App abgefragt.
</p>
</div>
<?php endif; // 2fa_active ?>
</div>
</div>
<?php endif; // class_exists WBF_TOTP ?>
<?php endif; /* end Tab 4 */ ?> <?php endif; /* end Tab 4 */ ?>
<!-- ══════════════════════════════════════════════════ <!-- ══════════════════════════════════════════════════
@@ -1551,7 +1805,7 @@ class WBF_Shortcodes {
<div class="wbf-profile-card__header"> <div class="wbf-profile-card__header">
<i class="fas fa-plug"></i> Verbundene Dienste <i class="fas fa-plug"></i> Verbundene Dienste
</div> </div>
<div class="wbf-profile-card__body" style="padding:0"> <div class="wbf-profile-card__body wbf-connections-body">
<?php if ( class_exists('MC_Gallery_Forum_Bridge') ) : <?php if ( class_exists('MC_Gallery_Forum_Bridge') ) :
$mc_content = apply_filters('wbf_profile_tab_content', '', 'minecraft', $profile); $mc_content = apply_filters('wbf_profile_tab_content', '', 'minecraft', $profile);
@@ -1561,7 +1815,7 @@ class WBF_Shortcodes {
<i class="fas fa-cubes" style="color:#65a30d"></i> <i class="fas fa-cubes" style="color:#65a30d"></i>
</div> </div>
<div class="wbf-connection-card__head"> <div class="wbf-connection-card__head">
<span class="wbf-connection-card__title">Minecraft</span> <span class="wbf-connection-card__title">Gallerie Verbindung</span>
</div> </div>
<div class="wbf-connection-card__content"> <div class="wbf-connection-card__content">
<?php echo $mc_content; ?> <?php echo $mc_content; ?>
@@ -1569,6 +1823,201 @@ class WBF_Shortcodes {
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php
// ── StatusAPI Bridge: Account-Verknüpfung & Ingame-Benachrichtigungen ──
$mc_enabled = class_exists( 'WBF_MC_Bridge' ) && WBF_MC_Bridge::is_enabled();
$mc_uuid = $mc_enabled ? WBF_MC_Bridge::get_mc_uuid( $profile->id ) : '';
$mc_name = $mc_enabled ? WBF_MC_Bridge::get_mc_name( $profile->id ) : '';
$mc_linked = ! empty( $mc_uuid );
?>
<div class="wbf-connection-card">
<div class="wbf-connection-card__icon" style="background:rgba(101,163,13,.15);border-color:rgba(101,163,13,.3)">
<i class="fas fa-cubes" style="color:#65a30d"></i>
</div>
<div class="wbf-connection-card__head">
<span class="wbf-connection-card__title">Minecraft InGame Verbindung</span>
<?php if ( ! $mc_enabled ) : ?>
<span class="wbf-connection-badge" style="color:#9ca3af;background:rgba(156,163,175,.1);border-color:rgba(156,163,175,.3)">
<i class="fas fa-circle-xmark"></i> Nicht konfiguriert
</span>
<?php elseif ( $mc_linked ) : ?>
<span class="wbf-connection-badge wbf-connection-badge--connected">
<i class="fas fa-check-circle"></i> Verbunden
</span>
<?php else : ?>
<span class="wbf-connection-badge wbf-connection-badge--disconnected">
<i class="fas fa-circle-xmark"></i> Nicht verbunden
</span>
<?php endif; ?>
</div>
<div class="wbf-connection-card__content">
<?php if ( ! $mc_enabled ) : ?>
<p class="wbf-connection-card__desc" style="color:var(--c-muted)">
<i class="fas fa-info-circle"></i>
Die Minecraft Bridge ist noch nicht eingerichtet.
Ein Admin muss sie zuerst in den Forum-Einstellungen aktivieren.
</p>
<?php elseif ( $mc_linked ) : ?>
<div class="wbf-mc-linked-info" style="display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem">
<img src="https://mc-heads.net/avatar/<?php echo urlencode( $mc_name ?: $mc_uuid ); ?>/40"
alt="" width="40" height="40"
style="border-radius:4px;image-rendering:pixelated">
<div>
<strong style="color:var(--c-text)"><?php echo esc_html( $mc_name ?: $mc_uuid ); ?></strong><br>
<small style="color:var(--c-muted);font-size:.75rem"><?php echo esc_html( $mc_uuid ); ?></small>
</div>
</div>
<p style="font-size:.82rem;color:var(--c-muted);margin:.25rem 0 .75rem">
<i class="fas fa-bell" style="color:#65a30d"></i>
Du erhältst Ingame-Benachrichtigungen bei Antworten, Erwähnungen und PNs.
</p>
<div id="wbf-mc-msg" style="font-size:.82rem;margin-bottom:.5rem"></div>
<button type="button" class="wbf-btn wbf-btn--ghost" id="wbf-mc-unlink-btn"
onclick="wbfMcUnlink()">
<i class="fas fa-unlink"></i> Verknüpfung aufheben
</button>
<?php else : ?>
<p class="wbf-connection-card__desc">
Verknüpfe deinen Minecraft-Account für Ingame-Benachrichtigungen
bei neuen Antworten, Erwähnungen und Privatnachrichten.
</p>
<p style="font-size:.82rem;color:var(--c-muted);margin-bottom:.75rem">
<i class="fas fa-terminal"></i>
Schritt 1: Token generieren &nbsp;→&nbsp;
Schritt 2: <code>/forumlink &lt;token&gt;</code> ingame eingeben
</p>
<div id="wbf-mc-token-box" style="display:none;background:var(--c-surface);border:1px solid var(--c-border);border-radius:8px;padding:.85rem 1rem;margin-bottom:.75rem">
<div style="display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.4rem">
<span style="font-size:.75rem;color:var(--c-muted);text-transform:uppercase;letter-spacing:.05em">Dein Token</span>
<span id="wbf-mc-token-timer" style="font-size:.75rem;color:#f97316;font-weight:600"></span>
</div>
<div style="display:flex;align-items:center;gap:.5rem">
<code id="wbf-mc-token-value"
style="font-size:1.4rem;letter-spacing:.25em;font-weight:700;color:var(--c-accent);flex:1"></code>
<button type="button" class="wbf-btn wbf-btn--sm" id="wbf-mc-copy-btn"
onclick="wbfMcCopyToken()" title="Befehl kopieren">
<i class="fas fa-copy"></i>
</button>
</div>
<div style="margin-top:.5rem;font-size:.8rem;color:var(--c-muted)">
Ingame eingeben: <code id="wbf-mc-cmd-value">/forumlink </code>
</div>
<div style="margin-top:.5rem">
<div style="height:4px;border-radius:2px;background:var(--c-border);overflow:hidden">
<div id="wbf-mc-token-progress"
style="height:100%;background:#65a30d;transition:width 1s linear;width:100%"></div>
</div>
</div>
</div>
<div id="wbf-mc-msg" style="font-size:.82rem;margin-bottom:.5rem"></div>
<button type="button" class="wbf-btn wbf-btn--primary" id="wbf-mc-gen-btn"
onclick="wbfMcGenerateToken()">
<i class="fas fa-key"></i> Token generieren
</button>
<?php endif; ?>
</div>
</div>
<script>
(function(){
var _pollInterval = null;
var _timerInterval = null;
var _expiry = 0;
window.wbfMcGenerateToken = function() {
var btn = document.getElementById('wbf-mc-gen-btn');
var msg = document.getElementById('wbf-mc-msg');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generiere...';
msg.textContent = '';
jQuery.post(WBF.ajax_url, { action: 'wbf_mc_generate_token', nonce: WBF.nonce }, function(r) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-rotate"></i> Neuen Token generieren';
if (r.success) {
var token = r.data.token;
_expiry = Math.floor(Date.now() / 1000) + ((r.data.expires_in || 15) * 60);
document.getElementById('wbf-mc-token-value').textContent = token;
document.getElementById('wbf-mc-cmd-value').textContent = '/forumlink ' + token;
document.getElementById('wbf-mc-token-box').style.display = 'block';
wbfMcStartTimer((r.data.expires_in || 15) * 60);
wbfMcStartPolling();
} else {
if (r.data && r.data.linked) {
msg.innerHTML = '<span style="color:#16a34a"><i class="fas fa-check-circle"></i> ' + (r.data.message || 'Bereits verknüpft.') + '</span>';
setTimeout(function(){ location.reload(); }, 1500);
} else {
msg.innerHTML = '<span style="color:#dc2626"><i class="fas fa-circle-xmark"></i> ' + (r.data && r.data.message ? r.data.message : 'Fehler') + '</span>';
}
}
});
};
function wbfMcStartTimer(seconds) {
clearInterval(_timerInterval);
var timerEl = document.getElementById('wbf-mc-token-timer');
var progressEl = document.getElementById('wbf-mc-token-progress');
var total = seconds;
_timerInterval = setInterval(function() {
var remaining = _expiry - Math.floor(Date.now() / 1000);
if (remaining <= 0) {
clearInterval(_timerInterval); clearInterval(_pollInterval);
if (timerEl) timerEl.textContent = 'Abgelaufen';
if (progressEl) progressEl.style.width = '0%';
var msg = document.getElementById('wbf-mc-msg');
if (msg) msg.innerHTML = '<span style="color:#f97316"><i class="fas fa-clock"></i> Token abgelaufen — bitte neuen generieren.</span>';
return;
}
var m = Math.floor(remaining / 60), s = remaining % 60;
if (timerEl) timerEl.textContent = m + ':' + (s < 10 ? '0' : '') + s;
if (progressEl) {
progressEl.style.width = Math.max(0, (remaining / total) * 100) + '%';
progressEl.style.background = remaining < 60 ? '#dc2626' : remaining < 180 ? '#f97316' : '#65a30d';
}
}, 1000);
}
function wbfMcStartPolling() {
clearInterval(_pollInterval);
_pollInterval = setInterval(function() {
jQuery.post(WBF.ajax_url, { action: 'wbf_mc_link_status', nonce: WBF.nonce }, function(r) {
if (r.success && r.data && r.data.linked) {
clearInterval(_pollInterval); clearInterval(_timerInterval);
var msg = document.getElementById('wbf-mc-msg');
if (msg) msg.innerHTML = '<span style="color:#16a34a"><i class="fas fa-check-circle"></i> ✓ Verknüpft mit <strong>' + (r.data.mc_name || r.data.mc_uuid) + '</strong>! Seite lädt neu...</span>';
setTimeout(function(){ location.reload(); }, 1800);
}
});
}, 5000);
}
window.wbfMcCopyToken = function() {
var cmd = document.getElementById('wbf-mc-cmd-value').textContent;
var btn = document.getElementById('wbf-mc-copy-btn');
var done = function() { btn.innerHTML = '<i class="fas fa-check"></i>'; btn.style.color = '#16a34a'; setTimeout(function(){ btn.innerHTML = '<i class="fas fa-copy"></i>'; btn.style.color = ''; }, 2000); };
if (navigator.clipboard) { navigator.clipboard.writeText(cmd).then(done); }
else { var ta = document.createElement('textarea'); ta.value = cmd; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); done(); }
};
window.wbfMcUnlink = function() {
if (!confirm('Minecraft-Verknüpfung wirklich aufheben?')) return;
var btn = document.getElementById('wbf-mc-unlink-btn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Trenne...';
jQuery.post(WBF.ajax_url, { action: 'wbf_mc_unlink', nonce: WBF.nonce }, function(r) {
if (r.success) {
var msg = document.getElementById('wbf-mc-msg');
if (msg) msg.innerHTML = '<span style="color:#16a34a"><i class="fas fa-check"></i> ' + (r.data.message || 'Verknüpfung aufgehoben.') + '</span>';
setTimeout(function(){ location.reload(); }, 1200);
} else {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-unlink"></i> Verknüpfung aufheben';
var msg = document.getElementById('wbf-mc-msg');
if (msg) msg.innerHTML = '<span style="color:#dc2626">Fehler: ' + (r.data && r.data.message ? r.data.message : 'Unbekannt') + '</span>';
}
});
};
})();
</script>
<?php <?php
// ── Discord-Card (eingebaut, kein extra Plugin nötig) ────────────── // ── Discord-Card (eingebaut, kein extra Plugin nötig) ──────────────
$discord_meta = WBF_DB::get_user_meta( $profile->id ); $discord_meta = WBF_DB::get_user_meta( $profile->id );
@@ -1657,6 +2106,7 @@ class WBF_Shortcodes {
</div><!-- /.wbf-profile-layout --> </div><!-- /.wbf-profile-layout -->
</div> </div>
</div> </div>
<?php self::render_auth_modal(); ?>
<?php return ob_get_clean(); <?php return ob_get_clean();
} }
@@ -2579,16 +3029,7 @@ class WBF_Shortcodes {
</div> </div>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
</div>
<?php if (WBF_Roles::level($current->role) >= 80): // Nur Admins/Mods ?>
<div style="margin-top:2.5rem;text-align:center">
<button class="wbf-btn wbf-btn--primary" id="wbf-discord-sync-btn" type="button">
<i class="fab fa-discord"></i> Discord-Rollen-Sync manuell anstoßen
</button>
<span id="wbf-discord-sync-msg" style="margin-left:1rem;font-size:.95em;display:none"></span>
</div>
<?php endif; ?>
<?php if (empty($members)): ?> <?php if (empty($members)): ?>
<div class="wbf-empty" style="grid-column:1/-1"> <div class="wbf-empty" style="grid-column:1/-1">
<i class="fas fa-users-slash"></i> <i class="fas fa-users-slash"></i>

View File

@@ -0,0 +1,179 @@
<?php
if ( ! defined( 'ABSPATH' ) ) exit;
/**
* WBF_TOTP — RFC 6238 Time-based One-Time Password (TOTP)
*
* Keine externe Bibliothek nötig — reines PHP 7.0+.
* Kompatibel mit: Google Authenticator, Aegis, Authy, Bitwarden, 2FAS, etc.
*
* Secrets werden in forum_user_meta gespeichert (meta_key = 'totp_secret').
* Kein Schema-Change an der Haupt-Usertabelle nötig.
*/
class WBF_TOTP {
const DIGITS = 6;
const PERIOD = 30; // Sekunden pro Schritt
const WINDOW = 1; // ±1 Step Toleranz (= ±30 s Uhrabweichung OK)
const SECRET_LEN = 20; // Bytes → 32 Base32-Zeichen
// Meta-Keys
const META_SECRET = 'totp_secret';
const META_PENDING = 'totp_secret_pending';
// Session-Key für ausstehenden Login
const SESSION_PENDING = 'wbf_2fa_pending';
// ── Secret ──────────────────────────────────────────────────────────────
/**
* Erzeugt einen neuen, kryptografisch sicheren Base32-Secret.
* @return string z.B. "JBSWY3DPEBLW64TMMQ======"
*/
public static function generate_secret() {
return self::base32_encode( random_bytes( self::SECRET_LEN ) );
}
// ── Verifikation ──────────────────────────────────────────────────────────
/**
* Prüft ob $code für $secret zum aktuellen Zeitfenster passt.
*
* @param string $secret Base32-Secret des Users
* @param string $code 6-stelliger Code aus der Authenticator-App
* @param int $window Anzahl Steps Toleranz (default = 1 = ±30 s)
* @return bool
*/
public static function verify( $secret, $code, $window = self::WINDOW ) {
// Leerzeichen tolerieren (z.B. "123 456")
$code = preg_replace( '/\s+/', '', (string) $code );
if ( strlen($code) !== self::DIGITS ) return false;
if ( ! ctype_digit($code) ) return false;
$key = self::base32_decode( $secret );
if ( empty($key) ) return false;
$ts = (int) floor( time() / self::PERIOD );
for ( $i = -$window; $i <= $window; $i++ ) {
$expected = self::hotp( $key, $ts + $i );
// Timing-safe Vergleich
if ( hash_equals( $expected, $code ) ) return true;
}
return false;
}
// ── HOTP-Kern (RFC 4226) ─────────────────────────────────────────────────
private static function hotp( $key, $counter ) {
// 64-bit Big-Endian Counter
$msg = pack( 'N', 0 ) . pack( 'N', $counter );
$hash = hash_hmac( 'sha1', $msg, $key, true );
$offset = ord( $hash[19] ) & 0x0f;
$code = (
( ord($hash[$offset ]) & 0x7f ) << 24 |
( ord($hash[$offset + 1]) & 0xff ) << 16 |
( ord($hash[$offset + 2]) & 0xff ) << 8 |
( ord($hash[$offset + 3]) & 0xff )
) % ( 10 ** self::DIGITS );
return str_pad( (string) $code, self::DIGITS, '0', STR_PAD_LEFT );
}
// ── otpauth:// URI ────────────────────────────────────────────────────────
/**
* Gibt die otpauth:// URI zurück — wird vom QR-Code-Generator verwendet.
*
* @param string $username Forum-Benutzername
* @param string $secret Base32-Secret
* @param string|null $issuer Anzeigename in der App (default: Blogname)
* @return string
*/
public static function get_otpauth_uri( $username, $secret, $issuer = null ) {
if ( ! $issuer ) {
$issuer = html_entity_decode( get_bloginfo('name'), ENT_QUOTES ) ?: 'WP Business Forum';
}
$label = rawurlencode( $issuer . ':' . $username );
return 'otpauth://totp/' . $label . '?'
. 'secret=' . rawurlencode( $secret )
. '&issuer=' . rawurlencode( $issuer )
. '&algorithm=SHA1'
. '&digits=' . self::DIGITS
. '&period=' . self::PERIOD;
}
// ── User-Helfer ───────────────────────────────────────────────────────────
/** Ist 2FA für diesen User aktiv? */
public static function is_enabled_for( $user_id ) {
$s = WBF_DB::get_user_meta_single( (int) $user_id, self::META_SECRET );
return ! empty( $s );
}
/**
* 2FA für einen User deaktivieren (löscht Secret + ggf. pending Secret).
* Kann von Admin und User selbst (nach Verifikation) aufgerufen werden.
*/
public static function disable_for( $user_id ) {
global $wpdb;
$uid = (int) $user_id;
$wpdb->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;
}
}

View File

@@ -0,0 +1,32 @@
<?php
// Ingame-Benachrichtigung via StatusAPI
if ( ! defined( 'ABSPATH' ) ) exit;
function wbf_notify_ingame($player, $message) {
// Einstellungen laden
$settings = function_exists('wbf_get_settings') ? wbf_get_settings() : [];
$enabled = !empty($settings['mc_bridge_enabled']);
$api_url = trim($settings['mc_bridge_api_url'] ?? '');
$api_secret = trim($settings['mc_bridge_api_secret'] ?? '');
if (!$enabled || !$api_url || !$api_secret) {
return;
}
$url = rtrim($api_url, '/') . '/notify-pn';
$data = [
'player' => $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);
}