<?php
/**
* Coming Soon page for a site the owner hasn't published yet.
*/
namespace Extendify\ComingSoon;
defined('ABSPATH') || die('No direct access.');
/**
* This class serves the Coming Soon page in place of any front-end request.
*
* Only loaded while the site is hidden, so nothing here rechecks it.
*/
class Frontend
{
/**
* REST namespaces that stay open while the site is hidden
*
* Empty on purpose: a hidden site answers nothing.
*
* @var array
*/
// phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound -- 7.0 floor: no const visibility
const OPEN_NAMESPACES = [];
/**
* Index fields a hidden site still exposes, as an allow list because core and plugins keep adding more
*
* @var array
*/
// phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound -- 7.0 floor: no const visibility
const OPEN_INDEX_FIELDS = ['url', 'home', 'authentication'];
/**
* Entry points that load WordPress without ever reaching the template or the REST server
*
* @var array
*/
// phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound -- 7.0 floor: no const visibility
const CLOSED_SCRIPTS = ['wp-comments-post.php', 'wp-trackback.php'];
/**
* Adds various actions to set up the page
*
* @return void
*/
public function __construct()
{
// Feeds and robots.txt reach template_redirect before core dispatches them.
\add_action('template_redirect', [$this, 'maybeRender']);
\add_action('admin_init', [$this, 'maybeBlockAjax']);
\add_filter('rest_authentication_errors', [$this, 'guardServedRest']);
\add_filter('rest_index', [$this, 'maybeTrimRestIndex']);
\add_filter('wp_sitemaps_is_enabled', '__return_false');
\add_filter('xmlrpc_enabled', '__return_false');
// xmlrpc_enabled misses pingback.ping, which reads a post without logging in.
\add_filter('xmlrpc_methods', '__return_empty_array');
\add_action('wp_loaded', [$this, 'maybeBlockClosedScripts']);
}
/**
* Arms the REST gate for requests that arrived over HTTP.
*
* Core only runs this filter from serve_request, so plugins dispatching
* internally keep working while the site is hidden.
*
* @param \WP_Error|null|true $errors Authentication errors so far.
* @return \WP_Error|null|true
*/
public function guardServedRest($errors)
{
\add_filter('rest_pre_dispatch', [$this, 'maybeBlockRest'], 10, 3);
return $errors;
}
/**
* Keeps site content out of the REST API while the site is hidden.
*
* @param mixed $result The response to send instead of dispatching.
* @param \WP_REST_Server $server The server handling the request.
* @param \WP_REST_Request $request The request being dispatched.
* @return mixed
*/
public function maybeBlockRest($result, $server, $request)
{
if ($this->isOpenRoute($request->get_route())) {
return $result;
}
return new \WP_Error(
'extendify_site_unpublished',
\__('This site is not ready for visitors yet.', 'extendify-local'),
['status' => 503]
);
}
/**
* Whether a REST route still answers while the site is hidden.
*
* @param string $route The route being dispatched.
* @return boolean
*/
private function isOpenRoute($route)
{
$route = trim($route, '/');
if ($route === '') {
return true;
}
foreach (self::OPEN_NAMESPACES as $namespace) {
if ($route === $namespace || strpos($route, $namespace . '/') === 0) {
return true;
}
}
return false;
}
/**
* Keeps the index from naming the site or inventorying its plugins.
*
* @param \WP_REST_Response $response The index response.
* @return \WP_REST_Response
*/
public function maybeTrimRestIndex($response)
{
$response->set_data(array_intersect_key(
$response->get_data(),
array_flip(self::OPEN_INDEX_FIELDS)
));
// The site logo returns through _links even when its field is stripped.
foreach (array_keys($response->get_links()) as $relation) {
$response->remove_link($relation);
}
return $response;
}
/**
* Keeps site content out of admin-ajax.php while the site is hidden.
*
* Other plugins register wp_ajax_nopriv_ handlers that read content, so the
* gate has to sit in front of the dispatch rather than on any one action.
*
* @return void
*/
public function maybeBlockAjax()
{
if (!\wp_doing_ajax()) {
return;
}
\nocache_headers();
\wp_send_json_error(
[
'code' => 'extendify_site_unpublished',
'message' => \__('This site is not ready for visitors yet.', 'extendify-local'),
],
503
);
}
/**
* Turns away the comment and trackback endpoints while the site is hidden.
*
* wp_loaded runs before either file looks up the post.
*
* @return void
*/
public function maybeBlockClosedScripts()
{
if (!in_array($this->currentScript(), self::CLOSED_SCRIPTS, true)) {
return;
}
\nocache_headers();
\wp_die(
\esc_html__('This site is not ready for visitors yet.', 'extendify-local'),
\esc_html__('Coming soon', 'extendify-local'),
['response' => 503]
);
}
/**
* The PHP file handling this request.
*
* @return string
*/
private function currentScript()
{
if (!isset($_SERVER['SCRIPT_NAME'])) {
return '';
}
return basename(\sanitize_text_field(\wp_unslash($_SERVER['SCRIPT_NAME'])));
}
/**
* Serves the Coming Soon page instead of whatever was requested.
*
* @return void
*/
public function maybeRender()
{
// 503 keeps search engines from indexing the placeholder as the site.
\status_header(503);
\nocache_headers();
$this->pageContent();
exit;
}
/**
* Coming Soon page output
*
* @return void
*/
private function pageContent()
{
$title = \__('Coming soon', 'extendify-local');
$message = \__('This site is not ready for visitors yet. Please check back soon.', 'extendify-local');
?>
<!DOCTYPE html>
<html <?php \language_attributes(); ?>>
<head>
<meta charset="<?php echo \esc_attr(\get_bloginfo('charset')); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title><?php echo \esc_html($title); ?></title>
<style>
body {
align-items: center;
background: #fff;
color: #1e1e1e;
display: flex;
font-family: system-ui, sans-serif;
justify-content: center;
margin: 0;
min-height: 100vh;
padding: 1.5rem;
text-align: center;
}
h1 {
font-size: 1.75rem;
margin: 0 0 0.5rem;
}
p {
margin: 0;
opacity: 0.7;
}
</style>
</head>
<body>
<main data-test="extendify-coming-soon">
<h1><?php echo \esc_html($title); ?></h1>
<p><?php echo \esc_html($message); ?></p>
</main>
</body>
</html>
<?php
}
}