value => label. */ public static function get_actions() { return array( self::ACTION_ENROLLED => __( 'Enrolled', 'accesstrace-for-learndash' ), self::ACTION_UNENROLLED => __( 'Unenrolled', 'accesstrace-for-learndash' ), ); } /** * Sources (extensible via filter). * * @return array */ public static function get_sources() { $sources = array( 'admin' => __( 'Admin', 'accesstrace-for-learndash' ), 'learndash' => __( 'LearnDash', 'accesstrace-for-learndash' ), 'membership' => __( 'Membership', 'accesstrace-for-learndash' ), 'woocommerce' => __( 'WooCommerce', 'accesstrace-for-learndash' ), 'edd' => __( 'Easy Digital Downloads', 'accesstrace-for-learndash' ), 'rest_api' => __( 'REST API', 'accesstrace-for-learndash' ), 'cron' => __( 'Cron', 'accesstrace-for-learndash' ), 'wp_cli' => __( 'WP-CLI', 'accesstrace-for-learndash' ), 'custom_code' => __( 'Custom code', 'accesstrace-for-learndash' ), 'import' => __( 'Import', 'accesstrace-for-learndash' ), 'unknown' => __( 'Unknown', 'accesstrace-for-learndash' ), ); return self::clean_vocab( apply_filters( 'evlfl_sources', $sources ), $sources ); } /** * Triggers (extensible via filter). * * @return array */ public static function get_triggers() { $triggers = array( 'manual' => __( 'Manual', 'accesstrace-for-learndash' ), 'membership_activated' => __( 'Membership activated', 'accesstrace-for-learndash' ), 'purchase' => __( 'Purchase', 'accesstrace-for-learndash' ), 'access_reset' => __( 'Access reset (repeat purchase)', 'accesstrace-for-learndash' ), 'api' => __( 'API', 'accesstrace-for-learndash' ), 'import' => __( 'Import', 'accesstrace-for-learndash' ), 'bulk_operation' => __( 'Bulk operation', 'accesstrace-for-learndash' ), 'custom_code' => __( 'Custom code', 'accesstrace-for-learndash' ), 'course_access_expired' => __( 'Course access expired', 'accesstrace-for-learndash' ), 'membership_expired' => __( 'Membership expired', 'accesstrace-for-learndash' ), 'membership_cancelled' => __( 'Membership cancelled', 'accesstrace-for-learndash' ), 'refund' => __( 'Refund', 'accesstrace-for-learndash' ), 'unknown' => __( 'Unknown', 'accesstrace-for-learndash' ), ); return self::clean_vocab( apply_filters( 'evlfl_triggers', $triggers ), $triggers ); } /** * Verification statuses. * * @return array */ public static function get_statuses() { return array( 'valid' => __( 'Valid', 'accesstrace-for-learndash' ), 'invalid' => __( 'Invalid', 'accesstrace-for-learndash' ), 'unknown' => __( 'Unknown', 'accesstrace-for-learndash' ), ); } /** * Confidence levels. * * @return array */ public static function get_confidences() { return array( 'confirmed' => __( 'Confirmed', 'accesstrace-for-learndash' ), 'detected' => __( 'Detected', 'accesstrace-for-learndash' ), 'inferred' => __( 'Inferred', 'accesstrace-for-learndash' ), 'unknown' => __( 'Unknown', 'accesstrace-for-learndash' ), ); } /** * Actor types. * * @return array */ public static function get_actor_types() { return array( 'admin' => __( 'Admin', 'accesstrace-for-learndash' ), 'user' => __( 'User', 'accesstrace-for-learndash' ), 'system' => __( 'System', 'accesstrace-for-learndash' ), 'cron' => __( 'Cron', 'accesstrace-for-learndash' ), 'api' => __( 'API', 'accesstrace-for-learndash' ), 'unknown' => __( 'Unknown', 'accesstrace-for-learndash' ), ); } /** * Human label for a vocabulary value; falls back to a readable form for * identifiers registered by future integrations. * * @param string $type One of action, source, trigger, status, confidence, actor_type. * @param string $value Stored value. * @return string */ public static function label( $type, $value ) { $map = array(); switch ( $type ) { case 'action': $map = self::get_actions(); break; case 'source': $map = self::get_sources(); break; case 'trigger': $map = self::get_triggers(); break; case 'status': $map = self::get_statuses(); break; case 'confidence': $map = self::get_confidences(); break; case 'actor_type': $map = self::get_actor_types(); break; } $value = (string) $value; if ( isset( $map[ $value ] ) ) { return $map[ $value ]; } return '' === $value ? '' : ucwords( str_replace( '_', ' ', $value ) ); } /** * Validate and sanitize an event array. Returns null when the event is unusable. * * @param array $event Raw event. * @return array|null */ public static function normalize( $event ) { if ( ! is_array( $event ) ) { return null; } $user_id = isset( $event['user_id'] ) ? absint( $event['user_id'] ) : 0; $course_id = isset( $event['course_id'] ) ? absint( $event['course_id'] ) : 0; $action = isset( $event['action'] ) ? sanitize_key( $event['action'] ) : ''; if ( $user_id < 1 || $course_id < 1 || ! isset( self::get_actions()[ $action ] ) ) { return null; } $actor_id = isset( $event['actor_id'] ) ? absint( $event['actor_id'] ) : 0; $time = isset( $event['event_time'] ) ? (string) $event['event_time'] : ''; if ( ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $time ) || false === strtotime( $time . ' UTC' ) ) { $time = gmdate( 'Y-m-d H:i:s' ); } $reason = isset( $event['verification_reason'] ) ? wp_strip_all_tags( (string) $event['verification_reason'] ) : ''; return array( 'user_id' => $user_id, 'course_id' => $course_id, 'action' => $action, 'source' => self::pick( $event, 'source', self::get_sources() ), 'event_trigger' => self::pick( $event, 'event_trigger', self::get_triggers() ), 'actor_type' => self::pick( $event, 'actor_type', self::get_actor_types() ), 'actor_id' => $actor_id > 0 ? $actor_id : null, 'status' => self::pick( $event, 'status', self::get_statuses() ), 'verification_reason' => '' === $reason ? null : substr( $reason, 0, 1000 ), 'confidence' => self::pick( $event, 'confidence', self::get_confidences() ), 'event_time' => $time, 'metadata' => self::clean_metadata( isset( $event['metadata'] ) ? $event['metadata'] : array() ), ); } /** * Pick an allowlisted value or "unknown". * * @param array $event Event. * @param string $key Key. * @param array $allow Allowlist (value => label). * @return string */ private static function pick( $event, $key, $allow ) { $value = isset( $event[ $key ] ) && is_scalar( $event[ $key ] ) ? sanitize_key( (string) $event[ $key ] ) : ''; return isset( $allow[ $value ] ) ? $value : 'unknown'; } /** * Clean a filtered vocabulary defensively (filter results are untrusted). * * @param mixed $filtered What the filter returned. * @param array $defaults Built-in entries (always kept). * @return array */ private static function clean_vocab( $filtered, $defaults ) { if ( ! is_array( $filtered ) ) { return $defaults; } $out = array(); foreach ( $filtered as $key => $label ) { $key = sanitize_key( (string) $key ); if ( '' !== $key && is_scalar( $label ) ) { $out[ substr( $key, 0, 50 ) ] = (string) $label; } } return $out + $defaults; } /** * Sanitize metadata: scalars/arrays only, limited depth, no sensitive keys. * * @param mixed $data Metadata. * @param int $depth Current depth. * @return array */ public static function clean_metadata( $data, $depth = 0 ) { $out = array(); if ( ! is_array( $data ) || $depth > 3 ) { return $out; } foreach ( $data as $key => $value ) { $key = is_int( $key ) ? $key : sanitize_key( (string) $key ); if ( '' === $key || ( is_string( $key ) && preg_match( '/pass|token|secret|nonce|cookie|auth|api_?key/i', $key ) ) ) { continue; } if ( is_array( $value ) ) { $out[ $key ] = self::clean_metadata( $value, $depth + 1 ); } elseif ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || null === $value ) { $out[ $key ] = $value; } elseif ( is_string( $value ) ) { $out[ $key ] = substr( sanitize_text_field( $value ), 0, 255 ); } } return $out; } }