settings = new Ads_Robots_Txt_Settings( $this->options ); add_action( 'init', [ $this, 'display_entries' ] ); add_filter( 'robots_txt', [ $this, 'output_robots_txt' ], 999999, 1 ); add_action( 'admin_init', [ $this, 'maybe_migrate_escaped_content' ] ); } /** * Display ads.txt and app-ads.txt content * * @return void */ public function display_entries() { $path = $this->resolve_request_path(); $content = null === $path ? null : $this->get_virtual_file_content( $path ); if ( null === $content ) { return; } header( 'Content-Type: text/plain; charset=utf-8' ); header( 'X-Content-Type-Options: nosniff' ); echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- stored plain-text file content, served verbatim as a text file, not HTML. die(); } /** * Resolve the current request's path, ignoring any query string, so * `/ads.txt?v=2` still matches the virtual file. * * @return string|null */ private function resolve_request_path(): ?string { if ( empty( $_SERVER['REQUEST_URI'] ) ) { return null; } $request = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); $path = wp_parse_url( $request, PHP_URL_PATH ); return is_string( $path ) ? $path : null; } /** * The virtual ads.txt / app-ads.txt content for a given request path, or * null when the path doesn't match a configured virtual file. * * @param string $path Request path (no query string). * @return string|null */ public function get_virtual_file_content( string $path ): ?string { if ( '/ads.txt' === $path && ! empty( $this->options['adstxt_content'] ) ) { return (string) $this->options['adstxt_content']; } if ( '/app-ads.txt' === $path && ! empty( $this->options['app_adstxt_content'] ) ) { return (string) $this->options['app_adstxt_content']; } return null; } /** * Output robots.txt content * * @param string $output robots.txt content. * @return string robots.txt content */ public function output_robots_txt( $output ) { if ( ! empty( $this->options['robotstxt_content'] ) ) { $output = (string) $this->options['robotstxt_content']; } return $output; } /** * One-time migration: values saved before the double-escaping fix are * stored HTML-entity-encoded. Decode them once, guarded by a stored * flag so repeat admin_init hits are a cheap no-op. * * @return void */ public function maybe_migrate_escaped_content(): void { if ( get_option( self::MIGRATED_OPTION ) ) { return; } $options = get_option( self::OPTION_NAME, [] ); $changed = false; foreach ( [ 'robotstxt_content', 'adstxt_content', 'app_adstxt_content' ] as $key ) { if ( ! empty( $options[ $key ] ) && is_string( $options[ $key ] ) ) { $options[ $key ] = wp_specialchars_decode( $options[ $key ], ENT_QUOTES ); $changed = true; } } if ( $changed ) { update_option( self::OPTION_NAME, $options ); $this->options = $options; } update_option( self::MIGRATED_OPTION, 1 ); } }