/` on the public gateway, where a root-relative * link points outside the site. Document-relative is the only form that works * in both places. * * @package SiriusPress */ defined( 'ABSPATH' ) || exit; final class SPE_Renderer { /** Marks a request as the exporter's own, so the site can render for it. */ const FLAG = 'sirius_export'; /** * Fetch a URL as an anonymous visitor would see it. * * @param string $url * @return array{ok:bool,body:string,mime:string,error:string} */ public static function fetch( $url ) { $request_url = add_query_arg( self::FLAG, self::token(), $url ); $response = wp_remote_get( $request_url, array( 'timeout' => 60, 'redirection' => 3, // No cookies: the export must be what a logged-out reader // sees, not what the admin who triggered it sees. 'cookies' => array(), 'sslverify' => apply_filters( 'sirius_press_export_sslverify', true ), 'headers' => array( 'user-agent' => 'SiriusPress/' . SIRIUS_PRESS_EXPORT_VERSION . ' (static export)', 'accept-encoding' => 'identity', ), ) ); if ( is_wp_error( $response ) ) { return self::error( $response->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return self::error( sprintf( 'the site returned HTTP %d for %s', $code, $url ) ); } $body = wp_remote_retrieve_body( $response ); if ( '' === $body ) { return self::error( sprintf( '%s rendered an empty page', $url ) ); } return array( 'ok' => true, 'body' => $body, 'mime' => (string) wp_remote_retrieve_header( $response, 'content-type' ), 'error' => '', ); } /** * A token proving a loopback request came from this site. * * Not a security boundary — the pages being fetched are public. It exists * so the site can recognise its own exporter and suppress things that * make no sense in a static copy (admin bar, nonce-bearing markup) without * giving a stranger a way to request that same altered output. */ public static function token() { return substr( hash_hmac( 'sha256', 'sirius-export', wp_salt( 'nonce' ) ), 0, 24 ); } /** True when the current request is the exporter fetching a page. */ public static function is_export_request() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return isset( $_GET[ self::FLAG ] ) && hash_equals( self::token(), sanitize_text_field( wp_unslash( $_GET[ self::FLAG ] ) ) ); } /** * Rewrite a page's URLs for life outside this host. * * @param string $html * @param string $doc_path Where this document will live, e.g. 'blog/hi/index.html'. * @return array{html:string,assets:array} Rewritten HTML and the * local assets it referenced, as path => absolute source URL. */ public static function rewrite( $html, $doc_path ) { $home = untrailingslashit( home_url() ); $site = untrailingslashit( site_url() ); $assets = array(); // The exporter's own marker must never survive into the output, or // every internal link in the static copy carries it. $html = preg_replace( '/([?&])' . preg_quote( self::FLAG, '/' ) . '=[^"\'&\s]*(&|&)?/', '$1', $html ); $html = str_replace( array( '?"', "?'" ), array( '"', "'" ), $html ); $bases = array_unique( array( $home, $site, set_url_scheme( $home, 'http' ), set_url_scheme( $home, 'https' ) ) ); // Protocol-relative references to our own host (`//example.test/…`) // are normalised to absolute first, so the pass below only has one // shape to match. // // The lookbehind is load-bearing: without it this also matches the // `//example.test/` sitting inside every `https://example.test/`, // and every absolute URL on the page gains a second scheme. foreach ( array_unique( array_filter( array( (string) wp_parse_url( $home, PHP_URL_HOST ), (string) wp_parse_url( $site, PHP_URL_HOST ), ) ) ) as $host ) { $html = preg_replace( '#(?\\\\)]*)?#'; $html = preg_replace_callback( $pattern, function ( $matches ) use ( $doc_path, &$assets ) { $path = isset( $matches[1] ) ? $matches[1] : '/'; return self::localise( $path, $doc_path, $assets ); }, $html ); return array( 'html' => $html, 'assets' => $assets, ); } /** * One absolute URL becomes one relative one, and any asset it names gets * remembered so the caller can queue it. */ private static function localise( $path, $doc_path, &$assets ) { $parts = wp_parse_url( $path ); $clean = isset( $parts['path'] ) ? $parts['path'] : '/'; $suffix = ( isset( $parts['query'] ) && '' !== $parts['query'] ? '?' . $parts['query'] : '' ) . ( isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '' ); $target = SPE_Mapper::path_for_url_path( $clean ); if ( '' === $target ) { // Nothing sensible to point at — an upload directory listing, a // path with characters the bucket will not take. Leave the URL // absolute rather than producing a link to nowhere. return untrailingslashit( home_url() ) . $path; } if ( SPE_Mapper::is_asset( $target ) ) { $assets[ $target ] = home_url( $clean ); // Assets are versioned with ?ver=; the bucket stores one copy, so // the query string would only ever produce a 404. $suffix = isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : ''; } return self::relative( $doc_path, $target ) . $suffix; } /** * Path of `$target` as seen from the document at `$from`. * * `blog/post/index.html` referring to `assets/app.css` yields * `../../assets/app.css`. */ public static function relative( $from, $target ) { $from_dir = explode( '/', $from ); array_pop( $from_dir ); // Drop the file name. $to = explode( '/', $target ); while ( $from_dir && $to && $from_dir[0] === $to[0] ) { array_shift( $from_dir ); array_shift( $to ); } $up = str_repeat( '../', count( $from_dir ) ); $rel = $up . implode( '/', $to ); return '' === $rel ? './' : $rel; } private static function error( $message ) { return array( 'ok' => false, 'body' => '', 'mime' => '', 'error' => $message, ); } /** * Strip the parts of a page that only make sense on the live site. * * Runs on the *live* request when the exporter is the one asking, which * is why it is here rather than in the rewriting pass: removing the admin * bar after the fact means also unpicking the styles and spacing it added. */ public static function hooks() { add_action( 'init', function () { if ( ! self::is_export_request() ) { return; } show_admin_bar( false ); /** * Fires on a page being rendered for static export. * * The place for a theme or plugin to drop anything that cannot * work without PHP behind it — a live search box, a comment * form, a cart widget. */ do_action( 'sirius_press_rendering_export' ); }, 1 ); } }