*/ namespace ReactPress\Admin; if (!defined('ABSPATH')) { exit; } use LengthException; use ReactPress\Admin\Utils; use function \repr_log; class Controller { // # Controller functions public static function add_page(string $appname, int $pageId, string $page_title) { $app_options = Utils::get_app_options($appname); $apptype = Utils::get_app_type($appname); //# Check if the app allows adding of more URL slugs if ($app_options && $app_options['allowsRouting'] && count($app_options['pageIds'])) { echo wp_json_encode([ 'status' => 0, 'message' => 'Apps with client-side routing can only be shown on one single page.' ]); return; } // add slug to existing app_options $inserted_page = Controller::insert_page($pageId, $page_title); if (!$inserted_page['ID']) { echo wp_json_encode([ 'status' => 0, 'message' => $inserted_page['message'] ]); return; } $permalink = get_permalink($inserted_page['ID']); $permalink = $permalink ? $permalink : ''; $app_options ? Utils::add_pageId_to_app_options($appname, $inserted_page['ID']) : Utils::add_app_options($appname, $inserted_page['ID']); Controller::add_build_path($appname, $apptype); if ($app_options['allowsRouting']) { add_rewrite_rule('^' . wp_make_link_relative($permalink) . '/(.*)?', 'index.php?pagename=' . wp_make_link_relative($permalink), 'top'); Utils::set_public_url_for_dev_server($appname, $permalink); } flush_rewrite_rules(); $html_content = Controller::get_index_html_content($permalink, $apptype, $appname); if (empty($html_content)) { echo wp_json_encode([ 'status' => 0, 'message' => 'Couldn\'t download page content.' ]); } elseif (Controller::write_index_html($appname, $html_content, $apptype)) { echo wp_json_encode([ 'status' => 1, 'message' => 'Page added.', 'pageId' => $inserted_page['ID'], 'page_title' => $inserted_page['page_title'], 'permalink' => $permalink ]); } else { echo wp_json_encode([ 'status' => 0, 'message' => 'Something went wrong.', ]); } } public static function delete_page(string $appname, int $pageId, string $permalink) { try { // Ignore the request permalink and use the page's own URL. The posted // value is written into package.json and must not be attacker-controlled. $stored_permalink = get_permalink($pageId); $permalink = is_string($stored_permalink) ? $stored_permalink : ''; $app_options_list = Utils::get_apps(); Utils::delete_page($app_options_list, $appname, $pageId); Utils::unset_public_url_for_dev_server($appname, $permalink); echo wp_json_encode(['status' => 1]); } catch (\Exception $e) { repr_log($e); echo wp_json_encode(['status' => 0, 'message' => $e->getMessage()]); } } public static function delete_react_app(string $appname) { $options = get_option('repr_apps'); Utils::write_apps_option(array_filter( $options, fn ($el) => $el['appname'] !== $appname )); $app_directory = self::resolve_app_directory($appname); $is_appdir_removed = $app_directory !== '' && repr_delete_directory($app_directory); if ($is_appdir_removed) { echo wp_json_encode([ 'status' => 1, 'message' => 'App deleted.', ]); } else { echo wp_json_encode([ 'status' => 1, 'message' => "Couldn't remove files. Please remove directory by hand.", ]); } } public static function get_react_apps() { $apps = Utils::get_apps(); echo wp_json_encode(['status' => 1, 'apps' => $apps]); } public static function update_index_html(string $appname, string $permalink) { $apptype = Utils::get_app_type($appname); $html_content = Controller::get_index_html_content($permalink, $apptype, $appname); if (empty($html_content)) { echo wp_json_encode([ 'status' => 0, 'message' => 'Couldn\'t download page content.' ]); } elseif (Controller::write_index_html($appname, $html_content, $apptype)) { echo wp_json_encode([ 'status' => 1, 'message' => 'Index.html updated.', ]); } else { echo wp_json_encode([ 'status' => 0, 'message' => 'Index.html could not be updated.', ]); } } // # Helper functions /** * Add the right build path to package.json * * @param string $appname * @return int 0 if no success * @since 1.2.0 */ public static function add_build_path(string $appname, string $apptype = 'development_cra'): int { $apppath = self::resolve_app_directory($appname); if ($apppath === '') { return 0; } // We need the relative path, that we can deploy our // built app to another server later. $relative_apppath = Utils::app_path($appname, true); $relative_apppath = $relative_apppath ? $relative_apppath : "/wp-content/reactpress/apps/{$appname}/"; if ($apptype === 'development_vite') { $homepage = "{$relative_apppath}/dist/"; $path_vite_config = is_file("{$apppath}/vite.config.js") ? "{$apppath}/vite.config.js" : "{$apppath}/vite.config.ts"; $vite_config_contents = file_get_contents($path_vite_config); if (!$vite_config_contents) { return 0; } elseif (stripos($vite_config_contents, $homepage)) { return 1; } else { // add the base pathe to vite.config.* file_put_contents( $path_vite_config, str_replace( "export default defineConfig({\n plugins: [react()],\n})", "export default defineConfig(({ command }) => {\n if (command === 'build') {\n return {\n base: \"{$homepage}\",\n plugins: [react()],\n }\n } else {\n return {\n plugins: [react()],\n }\n }\n})", $vite_config_contents ) ); return 2; } } elseif ($apptype === 'development_cra') { $homepage = "{$relative_apppath}/build"; $path_package_json = "{$apppath}/package.json"; $package_json_contents = file_get_contents($path_package_json); if (!$package_json_contents) { return 0; } elseif (stripos($package_json_contents, $homepage)) { return 1; } else { // add the base path that images are correctly loaded file_put_contents( $path_package_json, str_replace("react-scripts build", REPR_IS_WINDOWS ? "set PUBLIC_URL={$homepage}&&react-scripts build" : "PUBLIC_URL={$homepage} react-scripts build", $package_json_contents) ); return 2; } } return 0; } /** * Downloads the content of the page with the given permalink and removes the * the react assets of the build page, that we can use the content for our * development server. * * @param $permalink * @return string * @since 1.0.0 */ public static function get_index_html_content(string $permalink, string $apptype = 'development_cra', string $appname = ''): string { // Never request the raw permalink. Only the canonical URL of a page // already assigned to this app, on this site, may be downloaded. $permalink = self::canonical_app_permalink($appname, $permalink); if ($permalink === '') { return ''; } $response = self::fetch_local_permalink($permalink); if (is_wp_error($response)) { return ''; } if ((int) wp_remote_retrieve_response_code($response) !== 200) { return ''; } $content_type = self::header_value(wp_remote_retrieve_header($response, 'content-type')); if ( $content_type !== '' && stripos($content_type, 'text/html') === false && stripos($content_type, 'application/xhtml+xml') === false ) { return ''; } $body = wp_remote_retrieve_body($response); if ($body === '' || strlen($body) > 2 * 1024 * 1024) { return ''; } $filtered_contents = self::sanitize_index_html($body); if ($filtered_contents === '') { return ''; } // re-add script tag for global reactPress variable $readded_contents = str_replace('var reactPress', "", $filtered_contents ); } return $readded_contents; } /** * Creates or updates a page with the given name and title. * * @param int $pageId * @param string $page_title * @since 1.0.0 */ public static function insert_page(int $pageId, string $page_title) { if ($pageId === -1) { $result = wp_insert_post( array( 'post_title' => $page_title, 'post_status' => 'publish', 'post_content' => REPR_REACT_ROOT_TAG, 'post_type' => "page", // Assign page template using the relative path, it will be // resolved to the fully qualified name at run-time 'page_template' => 'templates/react-page-template.php', ) ); return $result ? ['status' => 'true', 'message' => 'Page created.', 'ID' => $result, 'page_title' => $page_title] : ['status' => 'false', 'message' => "Couldn't create page.", "ID" => $result, 'page_title' => $page_title]; } else { $page = get_post($pageId); if ($page) { // already we have data with this post name if (strpos($page->post_content, '
') !== false) { return ['status' => 'true', 'message' => 'Page with app already exists.', 'ID' => $page->ID, 'page_title' => $page_title]; } $result = wp_update_post( [ 'ID' => $page->ID, 'post_content' => $page->post_content . "\n\n" . REPR_REACT_ROOT_TAG ] ); return $result ? ['status' => 'true', 'message' => 'Add app to page.', 'ID' => $result, 'page_title' => $page->post_title] : ['status' => 'false', 'message' => 'Couldn\'t add app to page.', 'ID' => 0, 'page_title' => '']; } } } public static function toggle_react_routing(string $appname) { try { $app_options = Utils::get_apps(); $new_options = array_map(function ($el) use ($appname) { if ($el['appname'] === $appname) { //# create new allowsRouting state $allowsRouting = $el['allowsRouting'] ?? false; $el['allowsRouting'] = !$allowsRouting; //# change rewrite rules in wordpress if ($el['allowsRouting']) { // routing is only allowed for one single page if (count($el['pages']) > 1) { throw new LengthException('Client-side routing is only possible on one single page.'); } foreach ($el['pages'] as $page) { add_rewrite_rule( '^' . // Trim leading and trailing slashes to get `^foo/bar/(.*)?` not `^/foo/bar//(.*)?` trim(wp_make_link_relative($page['permalink']), '/') . '/(.*)?', 'index.php?pagename=' . // Trim leading and trailing slashes to get `index.php?pagename=foo/bar` not `index.php?pagename=/foo/bar/` trim(wp_make_link_relative($page['permalink']), '/'), 'top' ); Utils::set_public_url_for_dev_server($appname, $page['permalink']); } flush_rewrite_rules(); } else { foreach ($el['pages'] as $page) { Utils::remove_rewrite_rule('^' . wp_make_link_relative($page['permalink']) . '/(.*)?'); Utils::unset_public_url_for_dev_server($appname, $page['permalink']); } flush_rewrite_rules(); } return $el; } return $el; }, $app_options); Utils::write_apps_option($new_options); echo wp_json_encode(['status' => 1]); } catch (\Exception $e) { repr_log($e); echo wp_json_encode(['status' => 0, 'message' => $e->getMessage()]); } } /** * Writes the given to the index.html file in the app directory of * the given appname and produces if the writing of the file succeded or not. * * @param string $appname * @param string $content * @since 1.0.0 */ public static function write_index_html(string $appname, string $content, string $apptype = 'development_cra') { if ($content === '') { return false; } $dir = self::resolve_app_directory($appname); if ($dir === '') { return false; } if ($apptype === 'development_vite') { return file_put_contents($dir . '/index.html', $content, LOCK_EX); } elseif ($apptype === 'development_cra') { $public_dir = realpath($dir . '/public'); $dir_prefix = rtrim(str_replace('\\', '/', $dir), '/') . '/'; if ($public_dir === false || !is_dir($public_dir)) { return false; } $public_prefix = rtrim(str_replace('\\', '/', $public_dir), '/') . '/'; if (!str_starts_with($public_prefix, $dir_prefix)) { return false; } return file_put_contents($public_dir . '/index.html', $content, LOCK_EX); } return true; } /** * Canonical permalink of a page assigned to $appname, when $url points at * that page. Remote hosts and other local URLs return an empty string. */ private static function canonical_app_permalink(string $appname, string $url): string { $url = esc_url_raw($url); if ($url === '' || !self::is_same_site_url($url)) { return ''; } $wanted = self::normalized_url($url); if ($wanted === '') { return ''; } $options = Utils::get_app_options($appname); $page_ids = (is_array($options) && isset($options['pageIds']) && is_array($options['pageIds'])) ? $options['pageIds'] : []; foreach ($page_ids as $page_id) { if (!is_numeric($page_id)) { continue; } $permalink = get_permalink((int) $page_id); if (!is_string($permalink) || $permalink === '' || !self::is_same_site_url($permalink)) { continue; } if (self::normalized_url($permalink) === $wanted) { return $permalink; } } return ''; } /** * Download a same-site permalink. Redirects that leave the site are refused, * and authentication cookies are never sent to another host. * * @return array|\WP_Error */ private static function fetch_local_permalink(string $url) { $current = $url; for ($hop = 0; $hop < 3; $hop++) { if (!self::is_same_site_url($current)) { return new \WP_Error('repr_offsite_url', 'Refusing to request a URL outside this site.'); } $response = wp_safe_remote_get($current, [ 'timeout' => 20, 'redirection' => 0, 'limit_response_size' => 2 * 1024 * 1024, 'cookies' => self::local_auth_cookies(), ]); if (is_wp_error($response)) { return $response; } $code = (int) wp_remote_retrieve_response_code($response); if ($code < 300 || $code >= 400) { return $response; } $location = self::header_value(wp_remote_retrieve_header($response, 'location')); if ($location === '') { return $response; } $next = self::absolute_redirect_url($current, $location); if ($next === '' || !self::is_same_site_url($next)) { return new \WP_Error('repr_offsite_redirect', 'Refusing to follow a redirect off this site.'); } $current = $next; } return new \WP_Error('repr_too_many_redirects', 'Too many redirects.'); } /** * @return array */ private static function local_auth_cookies(): array { $cookies = []; foreach ($_COOKIE as $name => $value) { if (!is_string($name) || !is_string($value)) { continue; } if (!str_starts_with($name, 'wordpress_') && !str_starts_with($name, 'wp-')) { continue; } $cookies[$name] = wp_unslash($value); } return $cookies; } /** * True when $url uses http(s) and its host, port, and scheme match this site. */ private static function is_same_site_url(string $url): bool { $url = esc_url_raw($url); if ($url === '') { return false; } $parts = wp_parse_url($url); if (!is_array($parts) || empty($parts['host']) || empty($parts['scheme'])) { return false; } if (isset($parts['user']) || isset($parts['pass'])) { return false; } $scheme = strtolower((string) $parts['scheme']); if ($scheme !== 'http' && $scheme !== 'https') { return false; } $host = rtrim(strtolower((string) $parts['host']), '.'); $port = isset($parts['port']) ? (int) $parts['port'] : ($scheme === 'https' ? 443 : 80); foreach ([home_url(), site_url()] as $base) { $base_parts = wp_parse_url((string) $base); if (!is_array($base_parts) || empty($base_parts['host'])) { continue; } $base_scheme = strtolower((string) ($base_parts['scheme'] ?? 'http')); if ($base_scheme !== 'http' && $base_scheme !== 'https') { continue; } $base_host = rtrim(strtolower((string) $base_parts['host']), '.'); $base_port = isset($base_parts['port']) ? (int) $base_parts['port'] : ($base_scheme === 'https' ? 443 : 80); if ($scheme === $base_scheme && $host === $base_host && $port === $base_port) { return true; } } return false; } private static function normalized_url(string $url): string { $parts = wp_parse_url($url); if (!is_array($parts) || empty($parts['host']) || empty($parts['scheme'])) { return ''; } $scheme = strtolower((string) $parts['scheme']); $host = rtrim(strtolower((string) $parts['host']), '.'); $port = ''; if (isset($parts['port'])) { $port_number = (int) $parts['port']; $is_default = ($scheme === 'https' && $port_number === 443) || ($scheme === 'http' && $port_number === 80); if (!$is_default) { $port = ':' . $port_number; } } $path = isset($parts['path']) ? untrailingslashit((string) $parts['path']) : ''; $query = isset($parts['query']) && $parts['query'] !== '' ? '?' . $parts['query'] : ''; return $scheme . '://' . $host . $port . $path . $query; } private static function absolute_redirect_url(string $current, string $location): string { $location = trim($location); if ($location === '' || str_contains($location, "\n") || str_contains($location, "\r")) { return ''; } if (str_starts_with($location, '//')) { $scheme = wp_parse_url($current, PHP_URL_SCHEME); return is_string($scheme) ? $scheme . ':' . $location : ''; } $parts = wp_parse_url($location); if (is_array($parts) && !empty($parts['host'])) { return esc_url_raw($location); } $absolute = \WP_Http::make_absolute_url($location, $current); return esc_url_raw($absolute); } /** * @param mixed $header */ private static function header_value($header): string { if (is_array($header)) { $first = reset($header); return is_string($first) ? $first : ''; } return is_string($header) ? $header : ''; } /** * App directory inside REPR_APPS_PATH, or '' when $appname escapes that directory. */ private static function resolve_app_directory(string $appname): string { $appname = strtolower(sanitize_file_name($appname)); if ($appname === '' || !preg_match('/^[a-z0-9][a-z0-9._-]{0,100}$/', $appname)) { return ''; } $base = realpath(REPR_APPS_PATH); if ($base === false) { return ''; } $dir = realpath($base . DIRECTORY_SEPARATOR . $appname); if ($dir === false || !is_dir($dir)) { return ''; } $base_prefix = rtrim(str_replace('\\', '/', $base), '/') . '/'; $dir_prefix = rtrim(str_replace('\\', '/', $dir), '/') . '/'; if (!str_starts_with($dir_prefix, $base_prefix)) { return ''; } return $dir; } /** * Drop active content that must not be stored in a public index.html. * Theme stylesheets and same-site script files are kept so the dev server * can still mirror the WordPress page. wp_kses() is not used: it would * strip the stylesheet and script markup this file exists to preserve. * Inline event handlers, javascript: URLs, off-site script sources, and inline * scripts (other than the reactPress bootstrap) are removed. */ private static function sanitize_index_html(string $html): string { $dom = new \DOMDocument(); $previous = libxml_use_internal_errors(true); $loaded = $dom->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); libxml_clear_errors(); libxml_use_internal_errors($previous); if (!$loaded) { return ''; } foreach (['iframe', 'object', 'embed', 'base', 'form'] as $tag) { self::remove_elements_by_tag($dom, $tag); } $refresh_metas = []; foreach ($dom->getElementsByTagName('meta') as $meta) { if (strtolower($meta->getAttribute('http-equiv')) === 'refresh') { $refresh_metas[] = $meta; } } foreach ($refresh_metas as $meta) { if ($meta->parentNode instanceof \DOMNode) { $meta->parentNode->removeChild($meta); } } $xpath = new \DOMXPath($dom); $asset_nodes = $xpath->query('//*[starts-with(@id, "rp-react-app-asset-")]'); if ($asset_nodes instanceof \DOMNodeList) { $assets = []; foreach ($asset_nodes as $node) { $assets[] = $node; } foreach ($assets as $node) { if ($node->parentNode instanceof \DOMNode) { $node->parentNode->removeChild($node); } } } $elements = []; foreach ($dom->getElementsByTagName('*') as $element) { $elements[] = $element; } foreach ($elements as $element) { self::sanitize_element_attributes($element); } $scripts = []; foreach ($dom->getElementsByTagName('script') as $script) { $scripts[] = $script; } foreach ($scripts as $script) { $src = trim($script->getAttribute('src')); $type = strtolower($script->getAttribute('type')); $keep = in_array($type, ['application/json', 'application/ld+json'], true); if (!$keep && $src !== '') { $keep = self::is_allowed_resource_url($src, true); } elseif (!$keep) { $keep = str_contains((string) $script->textContent, 'reactPress'); } if (!$keep && $script->parentNode instanceof \DOMNode) { $script->parentNode->removeChild($script); } } $saved = $dom->saveHTML(); return is_string($saved) ? $saved : ''; } private static function remove_elements_by_tag(\DOMDocument $dom, string $tag): void { $nodes = []; foreach ($dom->getElementsByTagName($tag) as $node) { $nodes[] = $node; } foreach ($nodes as $node) { if ($node->parentNode instanceof \DOMNode) { $node->parentNode->removeChild($node); } } } private static function sanitize_element_attributes(\DOMElement $element): void { $remove = []; foreach ($element->attributes as $attr) { $name = strtolower($attr->nodeName); $value = (string) $attr->nodeValue; if (str_starts_with($name, 'on')) { $remove[] = $attr->nodeName; continue; } if (in_array($name, ['href', 'src', 'action', 'formaction', 'poster', 'xlink:href'], true)) { // Script sources are removed with the element below. Stripping only the // attribute would turn into an inline script. if ($name === 'src' && strtolower($element->tagName) === 'script') { continue; } if ($value !== '' && !self::is_allowed_resource_url($value, false)) { $remove[] = $attr->nodeName; } continue; } if ($name === 'srcset' && preg_match('/javascript:|data:/i', $value)) { $remove[] = $attr->nodeName; continue; } if ($name === 'style' && preg_match('/expression\s*\(|javascript:/i', $value)) { $remove[] = $attr->nodeName; } } foreach ($remove as $attr_name) { $element->removeAttribute($attr_name); } } private static function is_allowed_resource_url(string $url, bool $same_site_only): bool { $url = trim($url); if ($url === '' || preg_match('/[\x00-\x20]/', $url)) { return false; } $lower = strtolower($url); if (str_starts_with($lower, 'data:')) { return !$same_site_only && str_starts_with($lower, 'data:image/'); } foreach (['javascript:', 'vbscript:', 'file:'] as $scheme) { if (str_starts_with($lower, $scheme)) { return false; } } if (!$same_site_only && preg_match('#^(mailto|tel):#i', $url)) { return true; } if (str_starts_with($url, '//') || str_contains($url, '..')) { return false; } if (str_starts_with($url, '#') || str_starts_with($url, '?') || str_starts_with($url, '/')) { return true; } if (preg_match('#^https?://#i', $url)) { return $same_site_only ? self::is_same_site_url($url) : true; } return !preg_match('#^[a-z][a-z0-9+.-]*:#i', $url); } }