array{ handler: callable|mixed, label: string, refuse_test_session: bool }. * * @var array> */ private static $registered = array(); /** * Registers an admin-ajax handler. * * @param string $hook Full hook name, 'wp_ajax_' or 'wp_ajax_nopriv_'. * @param mixed $handler The handler, normally array( 'Class', 'method' ). Checked when the * action runs, not here: a class that loads later is still fine. * @param array $opts Options, keys from KNOWN_OPTIONS. * @return bool Whether it was registered. */ public static function register( string $hook, $handler, array $opts = array() ): bool { if ( 1 !== preg_match( '/^wp_ajax_(?:nopriv_)?[a-z0-9_]+$/', $hook ) ) { self::log( 'CSDT_Ajax: refused to register "' . $hook . '": not a wp_ajax_ hook name' ); return false; } if ( isset( self::$registered[ $hook ] ) ) { // Two handlers on one action both run, and the first to send a response ends the // request, so the second is dead code that looks alive. self::log( 'CSDT_Ajax: refused a second handler for "' . $hook . '" (' . self::label( $handler ) . '); ' . self::$registered[ $hook ]['label'] . ' already has it' ); return false; } $unknown = array_diff( array_keys( $opts ), self::KNOWN_OPTIONS ); $refuse = ! empty( $opts[ self::REFUSE_TEST_SESSION ] ); if ( $unknown ) { // A misspelled 'refuse_test_session' must not quietly become "allow". Fail closed. self::log( 'CSDT_Ajax: unknown option(s) ' . implode( ', ', $unknown ) . ' on "' . $hook . '"; refusing test sessions on it until fixed' ); $refuse = true; } self::$registered[ $hook ] = array( 'handler' => $handler, 'label' => self::label( $handler ), self::REFUSE_TEST_SESSION => $refuse, ); add_action( $hook, static function () use ( $hook ): void { self::dispatch( $hook ); } ); return true; } /** * Runs a registered handler: the test-session refusal first, then the handler inside a * safety net that turns an uncaught throw into a JSON error carrying a reference. * * Public so the tests can drive it; WordPress reaches it only through the closure above. * * @param string $hook Hook being run. * @return void * @throws \Throwable Only WordPress's own wp_die() test exceptions, re-thrown untouched. */ public static function dispatch( string $hook ): void { $entry = self::$registered[ $hook ] ?? null; if ( null === $entry ) { return; } if ( ! empty( $entry[ self::REFUSE_TEST_SESSION ] ) && is_callable( array( 'CSDT_DevTools', 'viewer_is_test_session' ) ) && CSDT_DevTools::viewer_is_test_session() ) { wp_send_json_error( self::TEST_SESSION_MESSAGE, 403 ); return; } if ( ! is_callable( $entry['handler'] ) ) { $ref = self::reference(); self::log( 'CSDT_Ajax: ' . $hook . ' [' . $ref . '] handler ' . $entry['label'] . ' is not callable' ); wp_send_json_error( self::failure_message( $ref ), 500 ); return; } try { call_user_func( $entry['handler'] ); } catch ( \Throwable $e ) { self::fail( $e, $hook ); } } /** * A handler's own catch-all: logs the throwable under a fresh reference and answers 500 * with that reference, the same answer the safety net in dispatch() gives an uncaught throw. * * Until 1.10.934 handlers that caught their own throws answered a bare "Internal error", * which nobody could match to a log line, or the exception's own text, which put file paths * and type errors in the browser (a nopriv passkey endpoint among them). * * @since 1.10.934 * @param \Throwable $e What was caught. * @param string $where Where, for the log: __METHOD__ or the hook. * @return void * @throws \Throwable Only WordPress's own wp_die() test exceptions, re-thrown untouched. */ public static function fail( \Throwable $e, string $where ): void { // wp_die() only throws under WordPress's own test harness (WPDieException and the // WPAjaxDie* pair); in production it exits. Those are the response, not a fault. if ( 1 === preg_match( '/^WP(?:Ajax)?Die/', get_class( $e ) ) ) { throw $e; } $ref = self::reference(); if ( is_callable( array( 'CSDT_Monitor', 'log_exception' ) ) ) { CSDT_Monitor::log_exception( $e, $where . ' [' . $ref . ']' ); } else { self::log( 'CSDT_Ajax: ' . $where . ' [' . $ref . '] ' . get_class( $e ) . ': ' . $e->getMessage() ); } wp_send_json_error( self::failure_message( $ref ), 500 ); } /** * Every registration, for the tests and diagnostics. Handlers are reported by label. * * @return array */ public static function registered(): array { $out = array(); foreach ( self::$registered as $hook => $entry ) { $out[ $hook ] = array( 'label' => $entry['label'], self::REFUSE_TEST_SESSION => (bool) $entry[ self::REFUSE_TEST_SESSION ], ); } return $out; } /** * Forgets every registration. For the tests, which register many in one process. * * @return void */ public static function reset(): void { self::$registered = array(); } /** * What the browser is told when a handler fails: no internals, one reference to quote. * * @param string $ref Reference. * @return string */ private static function failure_message( string $ref ): string { return 'Something went wrong on the server (reference ' . $ref . '). The details are in the plugin log under that reference.'; } /** * A short reference tying what the person saw to the log line. * * @return string e.g. "E-3F9A0C12". */ private static function reference(): string { try { $hex = bin2hex( random_bytes( 4 ) ); } catch ( \Throwable $e ) { $hex = substr( md5( uniqid( '', true ) ), 0, 8 ); } return 'E-' . strtoupper( $hex ); } /** * A readable name for a handler. * * @param mixed $handler Handler. * @return string */ private static function label( $handler ): string { if ( is_array( $handler ) && 2 === count( $handler ) ) { $owner = is_object( $handler[0] ) ? get_class( $handler[0] ) : (string) $handler[0]; return $owner . '::' . (string) $handler[1]; } if ( is_string( $handler ) ) { return $handler; } return $handler instanceof \Closure ? 'closure' : gettype( $handler ); } /** * Writes one diagnostic line. * * @param string $message Line. * @return void */ private static function log( string $message ): void { if ( is_callable( array( 'CSDT_DevTools', 'log' ) ) ) { CSDT_DevTools::log( $message ); return; } // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- the plugin class is not loaded, so PHP's log is the only sink error_log( $message ); } }