'index.php?prweb_doc=rsl' ] + $rules; } public function register_rsl_query_var( array $vars ): array { $vars[] = 'prweb_doc'; return $vars; } public function maybe_serve_rsl_xml(): void { if ( 'rsl' !== get_query_var( 'prweb_doc' ) ) { return; } if ( ! headers_sent() ) { status_header( 200 ); header( 'Content-Type: application/xml; charset=utf-8' ); header( 'Cache-Control: public, max-age=300' ); } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Complete RSL XML document generated internally; escaping would corrupt the served application/xml body. echo $this->generate_rsl_xml(); exit; } // ------------------------------------------------------------------------- // robots.txt // ------------------------------------------------------------------------- public function inject_robots_license( string $output, bool $public ): string { if ( ! $public ) { return $output; } $rsl_url = get_rest_url( null, 'pds/v1/rsl' ); return rtrim( $output, "\n" ) . "\n\nLicense: {$rsl_url}\n"; } // ------------------------------------------------------------------------- // Static file // ------------------------------------------------------------------------- /** * Apache serves any file at the doc root before WP's rewrite layer runs. * If the publisher previously enabled static mode and a stale rsl.xml is * sitting on disk, the `^rsl\.xml$` rewrite is dead-on-arrival. Clean * it up automatically when the operator switches back to dynamic mode. */ public function clean_stale_static_rsl(): void { $path = self::static_rsl_path(); if ( $path !== null && file_exists( $path ) ) { wp_delete_file( $path ); } } public function write_static_rsl_xml(): bool { $path = self::static_rsl_path(); if ( $path === null ) { return false; } return file_put_contents( $path, $this->generate_rsl_xml() ) !== false; } /** * Absolute path of the static rsl.xml: the site's document root, which is * what get_home_path() returns. ABSPATH is the install directory and only * coincides with the root when core is not in a subdirectory (ORL-244). * * Every writer, the settings page, the stale-file cleanup and uninstall * resolve through here, so the file is always removed from where it was * written. Returns null when the root cannot be resolved; callers fail * closed rather than guess. */ public static function static_rsl_path(): ?string { if ( ! function_exists( 'get_home_path' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } // Compared as a path prefix only; never output, never opened. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- see above; sanitize_text_field() would strip %-octets from a legitimate path. $script = isset( $_SERVER['SCRIPT_FILENAME'] ) ? (string) wp_unslash( $_SERVER['SCRIPT_FILENAME'] ) : ''; $root = self::resolve_site_root( ABSPATH, $script, get_home_path(), (string) get_option( 'home' ), (string) get_option( 'siteurl' ) ); if ( $root === null || ! is_dir( $root ) ) { return null; } return $root . 'rsl.xml'; } /** * Pick the document root, with trailing slash, or null to fail closed. * * get_home_path() is trusted only when the running script lives inside * the install directory (a web request through core) and its answer is * ABSPATH or an ancestor of it. On a subdirectory install it derives the * root from $_SERVER['SCRIPT_FILENAME']; under WP-CLI that is the CLI * entrypoint, so the result can be '/' or any unrelated directory that * happens to contain a segment named like the core subdirectory * (/opt/wp/wp-cli.phar yields /opt/). Every other case recovers the root * from the URL layout instead (ORL-244). */ public static function resolve_site_root( string $abspath, string $script_filename, string $home_path, string $home, string $siteurl ): ?string { $abspath = trailingslashit( wp_normalize_path( $abspath ) ); $script = wp_normalize_path( $script_filename ); if ( $script !== '' && str_starts_with( $script, $abspath ) ) { $candidate = trailingslashit( wp_normalize_path( $home_path ) ); if ( $candidate !== '/' && str_starts_with( $abspath, $candidate ) ) { return $candidate; } } $root = self::home_path_from_abspath( $abspath, $home, $siteurl ); return $root === null ? null : trailingslashit( $root ); } /** * Where releases before ORL-244 wrote the static file: the install * directory. Null when that is also the resolved location, i.e. there is * nothing to sweep. */ public static function legacy_static_rsl_path(): ?string { $legacy = trailingslashit( wp_normalize_path( ABSPATH ) ) . 'rsl.xml'; return $legacy === self::static_rsl_path() ? null : $legacy; } /** * The document root implied by the install directory and the two site * URLs: ABSPATH minus the path segment siteurl adds on top of home. Null * when the layout does not reduce that way (siteurl not under home, or * core not physically nested below the root). */ public static function home_path_from_abspath( string $abspath, string $home, string $siteurl ): ?string { $abspath = untrailingslashit( wp_normalize_path( $abspath ) ); $home_path = untrailingslashit( (string) wp_parse_url( $home, PHP_URL_PATH ) ); $site_path = untrailingslashit( (string) wp_parse_url( $siteurl, PHP_URL_PATH ) ); if ( $site_path === $home_path ) { return trailingslashit( $abspath ); } if ( $home_path !== '' && ! str_starts_with( $site_path, $home_path . '/' ) ) { return null; } $rel = trim( substr( $site_path, strlen( $home_path ) ), '/' ); if ( $rel === '' || ! str_ends_with( $abspath, '/' . $rel ) ) { return null; } $root = substr( $abspath, 0, -strlen( $rel ) ); return $root === '/' ? null : $root; } // ------------------------------------------------------------------------- // XML generation // ------------------------------------------------------------------------- public function generate_rsl_xml(): string { $source = $this->rule_source(); return Prweb_Rsl_Renderer::render( [ 'tokenUrl' => $source['tokenUrl'], 'rules' => $source['rules'], ] ); } /** * Build the PDS-backed rule source: the per-post DABs (status='open') plus * the site-wide catch-all, with the seller id and validator token URL the * publisher's PDS account resolves to. Rules use the private renderer's * flat-key contract (`sellerId`/`dabId`/`offerId`/`scope`) — byte-identical * with the SDK twin (`packages/sdk-ts/src/rsl/xml.ts`) and the * `shared/schemas/rsl-fixtures/` fixtures. * * This is the seam the OSS `openrsl-wordpress` plugin opts into via the * `openrsl_rsl_rule_source` filter: the private plugin keeps this * PDS-backed source private and maps it onto the OSS renderer's * binding-namespace contract in Prweb_Plugin::pds_rsl_rule_source(). The * OSS renderer never imports this class. * * @return array{tokenUrl:string,rules:array>} */ public function rule_source(): array { global $wpdb; $seller_id = (string) ( Prweb_Plugin::current_pds_user_id() ?? '' ); $validator_url = (string) get_option( Prweb_Brand::option( 'validator_url' ), 'http://localhost:16160' ); $token_url = rtrim( $validator_url, '/' ) . '/token'; if ( $seller_id === '' ) { return [ 'tokenUrl' => (string) esc_url_raw( $token_url ), 'rules' => [], ]; } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE pds_dab_id IS NOT NULL AND status = 'open'", Prweb_Brand::table( self::TABLE ) ) ); $rules = []; foreach ( $rows as $row ) { $post = get_post( $row->post_id ); if ( ! $post ) { continue; } $terms = json_decode( (string) $row->rsl_terms, true ); if ( ! is_array( $terms ) ) { $terms = []; } $permits = $terms['permits'] ?? null; if ( ! is_array( $permits ) ) { $permits = [ (string) ( $terms['permit'] ?? Prweb_Rsl_Renderer::DEFAULT_PERMITS ) ]; } $prohibits = $terms['prohibits'] ?? null; if ( ! is_array( $prohibits ) ) { $prohibits = [ (string) ( $terms['prohibit'] ?? Prweb_Rsl_Renderer::DEFAULT_PROHIBITS ) ]; } $rules[] = [ 'url' => (string) esc_url_raw( get_permalink( $post ) ), 'sellerId' => $seller_id, 'dabId' => (int) $row->pds_dab_id, 'offerId' => (int) $row->pds_offer_id, 'scope' => (string) ( $terms['scope'] ?? '' ), 'permits' => array_values( array_filter( array_map( 'strval', $permits ) ) ), 'prohibits' => array_values( array_filter( array_map( 'strval', $prohibits ) ) ), 'paymentType' => (string) ( $terms['paymentType'] ?? Prweb_Rsl_Renderer::DEFAULT_PAYMENT_TYPE ), ]; } // Site-wide catch-all rule: any URL not covered by a per-post rule // resolves to the site DAB. Crawlers can license once and walk the // whole site; readers are the same on the front-end via the modal. $site_dab = Prweb_Site_License::dab_id(); $site_offer = Prweb_Site_License::offer_id(); if ( $site_dab && $site_offer ) { $home_re = '^' . preg_quote( rtrim( home_url( '/' ), '/' ), '/' ) . '/.*$'; $rules[] = [ 'url' => $home_re, 'sellerId' => $seller_id, 'dabId' => (int) $site_dab, 'offerId' => (int) $site_offer, 'scope' => 'site', 'permits' => [ 'read', 'ai-crawl' ], 'prohibits' => [ 'ai-training' ], ]; } return [ 'tokenUrl' => (string) esc_url_raw( $token_url ), 'rules' => $rules, ]; } }