/.txt — the exact contract IndexNow (Bing/Yandex/Seznam, * NOT Google) requires to verify site ownership before accepting submissions. * * No file is written to disk: the key is served dynamically on template_redirect * (which fires on every front-end request, including would-be 404s), so there is * no rewrite rule and no flush to manage. * * The key is NOT a secret — the IndexNow protocol requires it to be publicly * served — so storing it in a plain option and echoing it verbatim is by design. */ final class IndexNow { const REST_NAMESPACE = 'agentsia/v1'; const REST_ROUTE = '/indexnow-key'; const OPTION_KEY = 'agentsia_indexnow_key'; private static ?self $instance = null; public static function instance(): self { if ( self::$instance === null ) { self::$instance = new self(); } return self::$instance; } public function register(): void { add_action( 'rest_api_init', [ $this, 'register_routes' ] ); // Priority 0 : intercept BEFORE WordPress resolves the (non-existent) /.txt to a 404 template. add_action( 'template_redirect', [ $this, 'maybe_serve_key' ], 0 ); } public function register_routes(): void { register_rest_route( self::REST_NAMESPACE, self::REST_ROUTE, [ 'methods' => 'POST', 'callback' => [ $this, 'handle_set_key' ], // Hosting a site-level ownership file is an administrator action. If the connected // Application Password user is NOT an admin, the backend receives a 403 and falls // back to manual hosting instructions — never a silent failure. // The dedicated « Agentsia » user (connection button) holds `agentsia_seo` instead of `manage_options`. 'permission_callback' => static function () { return current_user_can( 'manage_options' ) || current_user_can( 'agentsia_seo' ); }, ] ); } /** * Store the IndexNow key pushed by the backend. * * @return \WP_REST_Response|\WP_Error */ public function handle_set_key( \WP_REST_Request $request ) { $key = strtolower( trim( (string) $request->get_param( 'key' ) ) ); if ( ! preg_match( '/^[a-f0-9]{8,128}$/', $key ) ) { return new \WP_Error( 'agentsia_indexnow_bad_key', __( 'Clé IndexNow invalide (8 à 128 caractères hexadécimaux attendus).', 'agentsia-agents' ), [ 'status' => 400 ] ); } // autoload = false : lue seulement au moment de servir /.txt, pas sur chaque page. update_option( self::OPTION_KEY, $key, false ); return new \WP_REST_Response( [ 'stored' => true, 'keyLocation' => home_url( '/' . $key . '.txt' ), ], 200 ); } /** * Serve the stored key as raw text/plain when the request path is exactly /.txt. * No-op otherwise (lets WordPress handle the request normally). */ public function maybe_serve_key(): void { $key = (string) get_option( self::OPTION_KEY, '' ); if ( $key === '' ) { return; } $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; $path = (string) wp_parse_url( (string) $request_uri, PHP_URL_PATH ); $path = ltrim( rawurldecode( $path ), '/' ); // Only the exact ".txt" filename at the site root matches. Constant-time compare. if ( ! hash_equals( $key . '.txt', $path ) ) { return; } nocache_headers(); status_header( 200 ); header( 'Content-Type: text/plain; charset=UTF-8' ); // The key matches /^[a-f0-9]{8,128}$/ (validated on store) → safe to echo verbatim; the IndexNow // protocol requires the raw key as the entire body, with nothing else. echo $key; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped exit; } }