query_vars['airewp_file'] ?? ''; if ( empty( $file ) ) { return; } $settings = AI_Ready_WP::get_settings(); switch ( $file ) { case 'agents-json': // A disabled feature answers 404, it does not fall through. // Falling through hands a .json URL back to WordPress, which // canonical-redirects it and replies with an HTML page. if ( ! $settings['enable_agents_json'] ) { $this->send_404(); return; } $this->serve_json_path( '.well-known/agents.json', [ AIREWP_Agents_JSON::class, 'serve' ] ); break; case 'agent-card-json': if ( ! $settings['enable_agent_card'] ) { $this->send_404(); return; } $this->serve_json_path( '.well-known/agent-card.json', [ AIREWP_Agent_Card::class, 'serve' ] ); break; case 'agent-json': if ( ! $settings['enable_agent_card'] ) { $this->send_404(); return; } $this->serve_json_path( '.well-known/agent.json', [ AIREWP_Agent_Card::class, 'serve' ] ); break; case 'mcp-json': if ( ! $settings['enable_mcp_json'] ) { $this->send_404(); return; } $this->serve_json_path( '.well-known/mcp.json', [ AIREWP_MCP_JSON::class, 'serve' ] ); break; case 'llms-txt': if ( ! $settings['enable_llms_txt'] ) { $this->send_404(); return; } $this->serve_text_file( 'llms.txt' ); break; case 'llms-full-txt': if ( ! $settings['enable_llms_txt'] ) { $this->send_404(); return; } $this->serve_text_file( 'llms-full.txt' ); break; case 'indexnow-key': if ( ! $settings['enable_indexnow'] ) { $this->send_404(); return; } $key = $wp->query_vars['airewp_key'] ?? ''; if ( $key === $settings['indexnow_key'] ) { $this->send_text_response( $key ); } break; } } /** * Answer a discovery URL, from the written file when there is one. * * The file is only ever a cache. Everything served here can be rebuilt from * settings in memory, so a site that cannot write into .well-known/ still * answers correctly instead of returning 404 while holding the content. * That case is not exotic: certbot leaves .well-known/ owned by root on a * great many servers, and the plugin then has a writable site root and an * unwritable subdirectory in it. * * Order matters. The file wins when it is there, because the web server can * hand it out without ever starting PHP. * * @param string $path Path below the site root. * @param callable $fallback Serves the same document from memory. Exits. */ private function serve_json_path( string $path, callable $fallback ): void { $file = AIREWP_ROOT_PATH . $path; $fs = AI_Ready_WP::filesystem(); // No filesystem at all (an FTP transport without credentials, a // read-only mount): nothing to read, and nothing to write either, so // go straight to the dynamic answer rather than trying a rebuild. if ( null === $fs ) { call_user_func( $fallback ); return; } if ( ! $fs->exists( $file ) ) { // Still worth attempting: a site that CAN write gets a real file // out of this, and every later request is served without PHP. self::regenerate_on_demand(); } if ( $fs->exists( $file ) ) { $content = $fs->get_contents( $file ); if ( false !== $content ) { $decoded = json_decode( $content ); if ( null !== $decoded ) { self::send_json( $decoded ); } // A truncated or corrupted file is not a reason to fail the // request. Fall through and rebuild the answer from settings. } } call_user_func( $fallback ); } /** * Send a JSON document with the discovery response headers, then stop. * * The one place these headers are written, so the file path and the dynamic * path cannot answer with different caching or CORS rules. * * @param mixed $data Value to encode. */ public static function send_json( $data ): void { self::send_response_headers( 'json' ); echo wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); exit; } /** * Serve llms.txt and llms-full.txt from disk. * * Deliberately without the dynamic fallback that the JSON endpoints have. * Building these two means two WP_Query runs across every public post, and * robots.txt points crawlers straight at the URL, so an unwritable site * would pay that on every single hit. The JSON documents are built from * settings and cost nothing by comparison. */ private function serve_text_file( string $path ): void { $file = AIREWP_ROOT_PATH . $path; $fs = AI_Ready_WP::filesystem(); if ( null === $fs ) { $this->send_404(); return; } if ( ! $fs->exists( $file ) ) { self::regenerate_on_demand(); } if ( $fs->exists( $file ) ) { $content = $fs->get_contents( $file ); if ( false !== $content ) { self::send_response_headers( 'text' ); // Text content generated by this plugin, served as plain text (not HTML). echo wp_kses( $content, [] ); exit; } } // Falling through here handed the request back to WordPress, which then // canonical-redirected the URL and ended up answering with an HTML // page. Say plainly that the file is not there. $this->send_404(); } /** * Rebuild the generated files for a visitor request, at most once a minute. * * These two methods run for anonymous callers, so a site where the files * cannot be written turns every hit on /llms.txt into two WP_Query runs over * a hundred posts each plus two failed writes, and crawlers are pointed at * that URL by robots.txt. The throttle bounds the damage to one attempt a * minute; it is not a lock and does not pretend to be one. */ private static function regenerate_on_demand(): void { if ( get_transient( 'airewp_ondemand_rebuild' ) ) { return; } set_transient( 'airewp_ondemand_rebuild', 1, MINUTE_IN_SECONDS ); AI_Ready_WP::instance()->regenerate_all(); } private function send_404(): void { status_header( 404 ); header( 'Content-Type: application/json' ); echo wp_json_encode( [ 'error' => 'Not found' ] ); exit; } /** * Send common response headers with whitelisted content type. */ private static function send_response_headers( string $type ): void { $types = [ 'json' => 'application/json', 'text' => 'text/plain; charset=utf-8', ]; $content_type = $types[ $type ] ?? 'text/plain'; header( 'Content-Type: ' . $content_type ); header( 'Cache-Control: public, max-age=86400, stale-while-revalidate=604800' ); header( 'Access-Control-Allow-Origin: *' ); header( 'Access-Control-Allow-Methods: GET' ); header( 'X-Robots-Tag: noindex' ); } /** * Send a plain text response (for IndexNow key verification). */ private function send_text_response( string $content ): void { self::send_response_headers( 'text' ); echo esc_html( $content ); exit; } }