'POST', 'callback' => array( $this, 'handle_events' ), // Öffentlich: anonyme Besucher tracken. Schutz über strikte // Whitelist-Validierung + Batch-Limit. TODO Phase 1: Rate-Limit // pro session_key (Transient) gegen Flooding. 'permission_callback' => '__return_true', ) ); // Land des Besuchers aus CDN-Headern (Cloudflare etc.) — die IP wird // nur im Request gelesen, nie gespeichert. Ohne CDN-Header: 'xx'. // TODO Phase 2: optionale lokale GeoIP-Datenbank als Fallback. register_rest_route( self::NS, '/ctx', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_ctx' ), 'permission_callback' => '__return_true', ) ); register_rest_route( self::NS, '/stats', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_stats' ), 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, ) ); } public function handle_events( WP_REST_Request $request ) { if ( $this->rate_limited() ) { return new WP_Error( 'adsec_rate_limited', 'Too many requests.', array( 'status' => 429 ) ); } $batch = $request->get_json_params(); if ( ! is_array( $batch ) || empty( $batch['events'] ) || ! is_array( $batch['events'] ) ) { return new WP_Error( 'adsec_bad_request', 'Invalid payload.', array( 'status' => 400 ) ); } $clean = array(); foreach ( array_slice( $batch['events'], 0, 50 ) as $e ) { $event = $this->sanitize_event( $e ); if ( $event ) { $clean[] = $event; } } if ( $clean ) { ADSEC_Database::insert_events( $clean ); } return rest_ensure_response( array( 'stored' => count( $clean ) ) ); } /** Beste verfügbare Client-IP (hinter Cloudflare/Proxy die echte Besucher-IP, nicht die Proxy-IP). */ private function client_ip() { foreach ( array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP' ) as $h ) { if ( ! empty( $_SERVER[ $h ] ) ) { $ip = trim( sanitize_text_field( wp_unslash( $_SERVER[ $h ] ) ) ); if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { return $ip; } } } if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { $first = trim( explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) )[0] ); if ( filter_var( $first, FILTER_VALIDATE_IP ) ) { return $first; } } $ra = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; return filter_var( $ra, FILTER_VALIDATE_IP ) ? $ra : ''; } /** * Einfacher Flood-Schutz per Transient (fail-open): zählt Requests je Client-IP * im Zeitfenster. Ohne ermittelbare IP wird NICHT blockiert (keine Falsch-Positiven * für legitime Besucher). Verteilte Fluten brauchen später server-signierte Tokens. */ private function rate_limited() { $ip = $this->client_ip(); if ( ! $ip ) { return false; } $key = 'adsec_rl_' . md5( $ip ); $count = (int) get_transient( $key ); if ( $count >= self::RATE_MAX ) { return true; } set_transient( $key, $count + 1, self::RATE_WINDOW ); return false; } /** * Whitelist-Validierung: alles, was nicht exakt passt, wird verworfen. * Es werden niemals freie Strings vom Client gespeichert. */ private function sanitize_event( $e ) { if ( ! is_array( $e ) ) { return null; } $session_key = isset( $e['sk'] ) ? (string) $e['sk'] : ''; $section_id = isset( $e['s'] ) ? (string) $e['s'] : ''; $event_type = isset( $e['t'] ) ? (string) $e['t'] : ''; $ctx_bucket = isset( $e['c'] ) ? (string) $e['c'] : ''; $value = isset( $e['v'] ) ? (float) $e['v'] : 1.0; $page_id = isset( $e['p'] ) ? (int) $e['p'] : 0; $variant = isset( $e['va'] ) ? (string) $e['va'] : 'b'; if ( ! preg_match( '/^[a-f0-9]{16}$/', $session_key ) ) { return null; } if ( ! preg_match( '/^[a-f0-9]{8,32}$/', $section_id ) ) { return null; } if ( ! in_array( $event_type, self::EVENT_TYPES, true ) ) { return null; } if ( ! preg_match( self::CTX_PATTERN, $ctx_bucket ) || strlen( $ctx_bucket ) > 96 ) { return null; } if ( ! in_array( $variant, array( 'a', 'b', 'c' ), true ) ) { return null; } return array( 'session_key' => $session_key, 'page_id' => $page_id, 'section_id' => $section_id, 'event_type' => $event_type, 'value' => max( 0.0, min( $value, 120.0 ) ), // dwell: max 120 s pro Messung 'ctx_bucket' => $ctx_bucket, 'variant' => $variant, ); } public function handle_ctx() { $country = 'xx'; foreach ( array( 'HTTP_CF_IPCOUNTRY', 'HTTP_X_GEO_COUNTRY', 'HTTP_X_COUNTRY_CODE' ) as $header ) { $value = isset( $_SERVER[ $header ] ) ? sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) : ''; if ( '' !== $value && preg_match( '/^[A-Za-z]{2}$/', $value ) ) { $country = strtolower( $value ); break; } } return rest_ensure_response( array( 'country' => $country ) ); } public function handle_stats( WP_REST_Request $request ) { global $wpdb; $stats_table = ADSEC_Database::stats_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT section_id, ctx_bucket, trials, successes, updated_at FROM %i ORDER BY trials DESC LIMIT 200', $stats_table ) ); return rest_ensure_response( array( 'stats' => $rows ) ); } }