235 lines
7.9 KiB
PHP
235 lines
7.9 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* Turning a live WordPress page into a file that stands on its own.
|
||
|
|
*
|
||
|
|
* The export is fetched over a loopback HTTP request rather than rendered
|
||
|
|
* in-process. That costs a round trip, and it is worth it: a theme's output
|
||
|
|
* depends on the whole request lifecycle — the main query, `wp_head`,
|
||
|
|
* enqueued assets, late-filtered content, caching plugins. Re-creating that
|
||
|
|
* by calling `get_the_content()` produces something that looks like the page
|
||
|
|
* and is not the page, and the differences only show up in whichever theme
|
||
|
|
* the site owner actually uses.
|
||
|
|
*
|
||
|
|
* URLs are then rewritten to be document-relative. Not absolute, because the
|
||
|
|
* exported copy is served from the name, not from this WordPress host, and
|
||
|
|
* every absolute link would drag readers back to the origin the export exists
|
||
|
|
* to make optional. Not root-relative either, because the same bucket is
|
||
|
|
* browsable under `/bns/<name>/` 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<string,string>} 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(
|
||
|
|
'#(?<!:)//' . preg_quote( $host, '#' ) . '#',
|
||
|
|
$home,
|
||
|
|
$html
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
$pattern = '#(?:' . implode( '|', array_map( function ( $b ) {
|
||
|
|
return preg_quote( $b, '#' );
|
||
|
|
}, $bases ) ) . ')(/[^"\'\s<>\\\\)]*)?#';
|
||
|
|
|
||
|
|
$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
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|