1
0
Fork 0
mirror of https://github.com/sbrl/Pepperminty-Wiki.git synced 2024-06-29 06:14:55 +00:00
Pepperminty-Wiki/core/40-page-renderer.php
Starbeamrainbowlabs 39af83caf9
page-renderer: use htmlentities on admindetails_name
This is NOT to fix a security issue - rather to allow the admin's name 
to contain special characters. Note that the admin's name can only be 
changed either in peppermint.json or via the admin settings panel (which 
only admins can access). If you're worries about admins serving 
arbitrary HTML, then Pepperminty Wiki is not for you because they could 
serve a random static HTML file that they've uploaded to their web 
server for instance.
2021-09-03 00:09:44 +01:00

610 lines
21 KiB
PHP

<?php
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
/**
* Renders the HTML page that is sent to the client.
* @package core
*/
class page_renderer
{
/**
* The root HTML template that all pages are built from.
* @var string
* @package core
*/
public static $html_template = "<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8' />
<title>{title}</title>
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta name='generator' content='Pepperminty Wiki {version}' />
<meta name='application-name' content='Pepperminty Wiki {version}' />
<link rel='shortcut-icon' href='{favicon-url}' />
<link rel='icon' href='{favicon-url}' />
{header-html}
</head>
<body>
{body}
<!-- Took {generation-time-taken}ms to generate -->
</body>
</html>
";
/**
* The main content template that is used to render normal wiki pages.
* @var string
* @package core
*/
public static $main_content_template = "{navigation-bar}
<h1 class='sitename'>{sitename}</h1>
<main>
{content}
</main>
{extra}
<footer>
<p>{footer-message}</p>
<p>Powered by Pepperminty Wiki {version}, which was built by <a href='//starbeamrainbowlabs.com/'>Starbeamrainbowlabs</a>. Send bugs to 'bugs at starbeamrainbowlabs dot com' or <a href='//github.com/sbrl/Pepperminty-Wiki' title='Github Issue Tracker'>open an issue</a>.</p>
<p>Your local friendly moderators are {admins-name-list}.</p>
<p>This wiki is managed by {admin-details}.</p>
</footer>
{navigation-bar-bottom}
{all-pages-datalist}";
/**
* A specially minified content template that doesn't include the navbar and
* other elements not suitable for printing.
* @var string
* @package core
*/
public static $minimal_content_template = "<main class='printable'>{content}</main>
<footer class='printable'>
<hr class='footerdivider' />
<p><em>From {sitename}, which is managed by {admin-details-name}.</em></p>
<p>{footer-message}</p>
<p><em>Timed at {generation-date}</em></p>
<p><em>Powered by Pepperminty Wiki {version}.</em></p>
</footer>";
/**
* An array of items indicating the resources to ask the web server to push
* down to the client with HTTP/2.0 server push.
* Format: [ [type, path], [type, path], .... ]
* @var array[]
*/
protected static $http2_push_items = [];
/**
* A string of extrar HTML that should be included at the bottom of the page <head>.
* @var string
*/
private static $extraHeaderHTML = "";
/**
* The javascript snippets that will be included in the page.
* @var string[]
* @package core
*/
private static $jsSnippets = [];
/**
* The urls of the external javascript files that should be referenced
* by the page.
* @var string[]
* @package core
*/
private static $jsLinks = [];
/**
* The navigation bar divider.
* @package core
* @var string
*/
public static $nav_divider = "<span class='nav-divider inflexible'> | </span>";
/**
* An array of functions that have been registered to process the
* find / replace array before the page is rendered. Note that the function
* should take a *reference* to an array as its only argument.
* @var array
* @package core
*/
protected static $part_processors = [];
/**
* Registers a function as a part post processor.
* This function's use is more complicated to explain. Pepperminty Wiki
* renders pages with a very simple templating system. For example, in the
* template a page's content is denoted by `{content}`. A function
* registered here will be passed all the components of a page _just_
* before they are dropped into the template. Note that the function you
* pass in here should take a *reference* to the components, as the return
* value of the function passed is discarded.
* @package core
* @param callable $function The part preprocessor to register.
*/
public static function register_part_preprocessor($function) {
global $settings;
// Make sure that the function we are about to register is valid
if(!is_callable($function))
{
http_response_code(500);
exit(page_renderer::render("$settings->sitename - Module Error", "<p>$settings->sitename has got a misbehaving module installed that tried to register an invalid HTML handler with the page renderer. Please contact $settings->sitename's administrator {$settings->admindetails_name} at ".hide_email($settings->admindetails_email)."."));
}
self::$part_processors[] = $function;
return true;
}
/**
* Renders a HTML page with the content specified.
* @package core
* @param string $title The title of the page.
* @param string $content The (HTML) content of the page.
* @param bool $body_template The HTML content template to use.
* @return string The rendered HTML, ready to send to the client :-)
*/
public static function render($title, $content, $body_template = false)
{
global $settings, $env, $start_time, $version;
// Hrm, we can't seem to get this working
// This example URL works: https://httpbin.org/response-headers?Server=httpbin&Content-Type=text%2Fplain%3B+charset%3DUTF-8&Server-Timing=sql-1%3Bdesc%3D%22MySQL%20lookup%20Server%22%3Bdur%3D100%2Csql-2%3Bdur%3D900%3Bdesc%3D%22MySQL%20shard%20Server%20%231%22%2Cfs%3Bdur%3D600%3Bdesc%3D%22FileSystem%22%2Ccache%3Bdur%3D300%3Bdesc%3D%22Cache%22%2Cother%3Bdur%3D200%3Bdesc%3D%22Database%20Write%22%2Cother%3Bdur%3D110%3Bdesc%3D%22Database%20Read%22%2Ccpu%3Bdur%3D1230%3Bdesc%3D%22Total%20CPU%22
// ..... but setting headers here doesn't (though we haven't tried sending an identical header to the above example yet)
// header("Server-Timing: foo;desc=\"Test\";dur=123");
// header("Server-Timing: ".metrics2servertiming($env->perfdata));
if($body_template === false)
$body_template = self::$main_content_template;
if(strlen($settings->logo_url) > 0) {
// A logo url has been specified
$logo_html = "<img aria-hidden='true' class='logo" . (isset($_GET["printable"]) ? " small" : "") . "' src='$settings->logo_url' />";
switch($settings->logo_position) {
case "left":
$logo_html = "$logo_html $settings->sitename";
break;
case "right":
$logo_html .= " $settings->sitename";
break;
default:
throw new Exception("Invalid logo_position '$settings->logo_position'. Valid values are either \"left\" or \"right\" and are case sensitive.");
}
}
// Push the logo via HTTP/2.0 if possible
if($settings->favicon[0] === "/") self::$http2_push_items[] = ["image", $settings->favicon];
$parts = [
"{body}" => $body_template,
"{sitename}" => $logo_html,
"{version}" => $version,
"{favicon-url}" => $settings->favicon,
"{header-html}" => self::get_header_html(),
"{navigation-bar}" => self::render_navigation_bar($settings->nav_links, $settings->nav_links_extra, "top"),
"{navigation-bar-bottom}" => self::render_navigation_bar($settings->nav_links_bottom, [], "bottom"),
"{admin-details}" => hide_email(
$settings->admindetails_email,
htmlentities($settings->admindetails_name)
),
"{admin-details-name}" => $settings->admindetails_name,
"{admins-name-list}" => implode(", ", array_map(function($username) { return page_renderer::render_username($username); }, $settings->admins)),
"{generation-date}" => date("l jS \of F Y \a\\t h:ia T"),
"{all-pages-datalist}" => self::generate_all_pages_datalist(),
"{footer-message}" => $settings->footer_message,
/// Secondary Parts ///
"{content}" => $content,
"{extra}" => "",
"{title}" => htmlentities($title),
];
// Pass the parts through the part processors
foreach(self::$part_processors as $function) {
$function($parts);
}
$result = self::$html_template;
$result = str_replace(array_keys($parts), array_values($parts), $result);
$result = str_replace("{generation-time-taken}", round((microtime(true) - $start_time)*1000, 2), $result);
// Send the HTTP/2.0 server push indicators if possible - but not if we're sending a redirect page
if(!headers_sent() && (http_response_code() < 300 || http_response_code() >= 400)) self::send_server_push_indicators();
return $result;
}
/**
* Renders a normal HTML page.
* @package core
* @param string $title The title of the page.
* @param string $content The content of the page.
* @return string The rendered page.
*/
public static function render_main($title, $content) {
return self::render($title, $content, self::$main_content_template);
}
/**
* Renders a minimal HTML page. Useful for printable pages.
* @package core
* @param string $title The title of the page.
* @param string $content The content of the page.
* @return string The rendered page.
*/
public static function render_minimal($title, $content) {
return self::render($title, $content, self::$minimal_content_template);
}
/**
* Sends the currently registered HTTP2 server push items to the client.
* @return int|false The number of resource hints included in the link: header, or false if server pushing is disabled.
*/
public static function send_server_push_indicators() {
global $settings;
if(!$settings->http2_server_push)
return false;
// Render the preload directives
$link_header_parts = [];
foreach(self::$http2_push_items as $push_item)
$link_header_parts[] = "<{$push_item[1]}>; rel=preload; as={$push_item[0]}";
// Send them in a link: header
if(!empty($link_header_parts))
header("link: " . implode(", ", $link_header_parts));
return count(self::$http2_push_items);
}
/**
* Renders the header HTML.
* @package core
* @return string The rendered HTML that goes in the header.
*/
public static function get_header_html()
{
global $settings;
$result = self::$extraHeaderHTML;
$result .= self::get_css_as_html();
$result .= self::_get_js();
if(!empty($settings->theme_colour))
$result .= "\t\t<meta name='theme-color' content='$settings->theme_colour' />\n";
// We can't use module_exists here because sometimes global $modules
// hasn't populated yet when we get called O.o
if(class_exists("search"))
$result .= "\t\t<link rel='search' type='application/opensearchdescription+xml' href='?action=opensearch-description' title='$settings->sitename Search' />\n";
if(!empty($settings->enable_math_rendering)) {
$result .= "<script type='text/x-mathjax-config'>
MathJax.Hub.Config({
tex2jax: {
inlineMath: [ ['$','$'], ['\\\\(','\\\\)'] ],
processEscapes: true,
skipTags: ['script','noscript','style','textarea','pre','code']
}
});
</script>";
}
return $result;
}
/**
* Figures out whether $settings->css is a url, or a string of css.
* A url is something starting with "protocol://" or simply a "/".
* Before v0.20, this method took no arguments and checked $settings->css directly.
* @apiVerion 0.20.0
* @param string $str The CSS string to check.
* @return bool True if it's a url - false if we assume it's a string of css.
*/
public static function is_css_url($str) {
global $settings;
return preg_match("/^[^\/]*\/\/|^\/[^\*]/", $str);
}
/**
* Renders all the CSS as HTML.
* @package core
* @return string The css as HTML, ready to be included in the HTML header.
*/
public static function get_css_as_html()
{
global $settings, $defaultCSS;
$result = "";
$css = "";
if(self::is_css_url($settings->css)) {
if($settings->css[0] === "/") // Push it if it's a relative resource
self::add_server_push_indicator("style", $settings->css);
$result .= "<link rel='stylesheet' href='$settings->css' />\n";
} else {
$css .= $settings->css == "auto" ? $defaultCSS : $settings->css;
if(!empty($settings->optimize_pages))
$css = minify_css($css);
}
if(!empty($settings->css_custom)) {
if(self::is_css_url($settings->css_custom)) {
if($settings->css_custom[0] === "/") // Push it if it's a relative resource
self::add_server_push_indicator("style", $settings->css);
$result .= "<link rel='stylesheet' href='$settings->css_custom' />\n";
}
if(!empty(trim($settings->css_custom))) {
$css .= "\n/*** Custom CSS ***/\n";
$css .= !empty($settings->optimize_pages) ? minify_css($settings->css_custom) : $settings->css_custom;
$css .= "\n/******************/";
}
}
$result .= "<style>\n$css\n</style>\n";
return $result;
}
/**
* Adds the specified url to a javascript file as a reference to the page.
* @package core
* @param string $scriptUrl The url of the javascript file to reference.
*/
public static function add_js_link(string $scriptUrl) {
static::$jsLinks[] = $scriptUrl;
}
/**
* Adds a javascript snippet to the page.
* @package core
* @param string $script The snippet of javascript to add.
*/
public static function add_js_snippet(string $script) {
static::$jsSnippets[] = $script;
}
/**
* Renders the included javascript header for inclusion in the final
* rendered page.
* @package core
* @return string The rendered javascript ready for inclusion in the page.
*/
private static function _get_js() {
$result = "<!-- Javascript -->\n";
foreach(static::$jsSnippets as $snippet)
$result .= "<script defer>\n$snippet\n</script>\n";
foreach(static::$jsLinks as $link) {
// Push it via HTTP/2.0 if it's relative
if($link[0] === "/") self::add_server_push_indicator("script", $link);
$result .= "<script src='" . $link . "' defer></script>\n";
}
return $result;
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/**
* Adds a string of HTML to the header of the rendered page.
* @param string $html The string of HTML to add.
*/
public static function add_header_html($html) {
self::$extraHeaderHTML .= $html;
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/**
* Adds a resource to the list of items to indicate that the web server should send via HTTP/2.0 Server Push.
* Note: Only specify static files here, as you might end up with strange (and possibly dangerous) results!
* @param string $type The resource type. See https://fetch.spec.whatwg.org/#concept-request-destination for more information.
* @param string $path The *relative url path* to the resource.
*/
public static function add_server_push_indicator($type, $path) {
self::$http2_push_items[] = [ $type, $path ];
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/**
* Renders a navigation bar from an array of links. See
* $settings->nav_links for format information.
* @package core
* @param array $nav_links The links to add to the navigation bar.
* @param array $nav_links_extra The extra nav links to add to
* the "More..." menu.
* @param string $class The class(es) to assign to the rendered
* navigation bar.
*/
public static function render_navigation_bar($nav_links, $nav_links_extra, $class = "") {
global $settings, $env;
$mega_menu = false;
if(is_object($nav_links)) {
$mega_menu = true;
$class = trim("$class mega-menu");
$links_list = [];
$keys = array_keys(get_object_vars($nav_links));
foreach($keys as $key) {
$links_list[] = "category\0$key";
$links_list = array_merge(
$links_list,
$nav_links->$key
);
}
$nav_links = $links_list;
}
$result = "<nav class='$class'>\n";
$is_first_category = true;
// Loop over all the navigation links
foreach($nav_links as $item) {
if(!is_string($item)) {
// Output the item as a link to a url
$result .= "<span><a href='" . str_replace(
[ "{page}", "&" ],
[ rawurlencode($env->page), "&amp;" ],
$item[1]
) . "'>$item[0]</a></span>";
continue;
}
// Extract the item key - a null character can be used to separate extra data from an item type
$item_key = $item;
if(strpos($item_key, "\0") !== false)
$item_key = substr($item_key, 0, strpos($item_key, "\0"));
// The item is a string
switch($item_key) {
//keywords
case "user-status": // Renders the user status box
if($env->is_logged_in) {
$result .= "<span class='inflexible logged-in" . ($env->is_logged_in ? " moderator" : " normal-user") . "'>";
if(module_exists("feature-user-preferences")) {
$result .= "<a href='?action=user-preferences' aria-label='Change user preferences'>$settings->user_preferences_button_text</a>";
}
$result .= self::render_username($env->user);
$result .= " <small>(<a href='index.php?action=logout'>Logout</a>)</small>";
$result .= "</span>";
//$result .= page_renderer::$nav_divider;
}
else {
$returnto_url = $env->action !== "logout" ? $_SERVER["REQUEST_URI"] : "?action=view&page=" . rawurlencode($settings->defaultpage);
$result .= "<span class='not-logged-in'><a href='index.php?action=login&returnto=" . rawurlencode($returnto_url) . "' rel='nofollow'>Login</a></span>";
}
break;
case "search": // Renders the search bar
$result .= "<span class='inflexible'><form method='get' action='index.php' style='display: inline;'><input type='search' name='page' list='allpages' placeholder='&#x1f50e; Type a page name here and hit enter' /><input type='hidden' name='search-redirect' value='true' /></form></span>";
break;
case "divider": // Renders a divider
$result .= page_renderer::$nav_divider;
break;
case "menu": // Renders the "More..." menu
$result .= "<span class='inflexible nav-more'><label for='more-menu-toggler'>More...</label>
<input type='checkbox' class='off-screen' id='more-menu-toggler' />";
$result .= page_renderer::render_navigation_bar($nav_links_extra, [], "nav-more-menu");
$result .= "</span>";
break;
case "category": // Renders a category header
if(!$is_first_category) $result .= "</span>";
$result .= "<span class='category'><strong>" . substr($item, 9) . "</strong>";
$is_first_category = false;
break;
// It isn't a keyword, so just output it directly
default:
$result .= "<span>$item</span>";
}
}
if($mega_menu) $result .= "</span>";
$result .= "</nav>";
return $result;
}
/**
* Renders a username for inclusion in a page.
* @package core
* @param string $name The username to render.
* @return string The username rendered in HTML.
*/
public static function render_username($name) {
global $settings;
$result = "";
$result .= "<a href='?page=" . rawurlencode(get_user_pagename($name)) . "'>";
if($settings->avatars_show)
$result .= "<img class='avatar' aria-hidden='true' src='?action=avatar&user=" . urlencode($name) . "&size=$settings->avatars_size' /> ";
if(in_array($name, $settings->admins))
$result .= $settings->admindisplaychar;
$result .= htmlentities($name);
$result .= "</a>";
return $result;
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/**
* Renders the datalist for the search box as HTML.
* @package core
* @return string The search box datalist as HTML.
*/
public static function generate_all_pages_datalist() {
global $settings, $pageindex;
$result = "<datalist id='allpages'>\n";
// If dynamic page sugggestions are enabled, then we should send a loading message instead.
if($settings->dynamic_page_suggestion_count > 0) {
$result .= "<option value='Loading suggestions...' />";
} else {
$arrayPageIndex = get_object_vars($pageindex);
$sorter = new Collator("");
uksort($arrayPageIndex, function($a, $b) use($sorter) : int {
return $sorter->compare($a, $b);
});
foreach($arrayPageIndex as $pagename => $pagedetails) {
$escapedPageName = str_replace('"', '&quot;', $pagename);
$result .= "\t\t\t<option value=\"$escapedPageName\" />\n";
}
}
$result .= "\t\t</datalist>";
return $result;
}
}
// HTTP/2.0 Server Push static items
foreach($settings->http2_server_push_items as $push_item) {
page_renderer::add_server_push_indicator($push_item[0], $push_item[1]);
}
// Math rendering support
if(!empty($settings->enable_math_rendering))
{
page_renderer::add_js_link("https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-MML-AM_CHTML");
}
// alt+enter support in the search box
page_renderer::add_js_snippet('// Alt + Enter support in the top search box
window.addEventListener("load", function(event) {
let search_box = document.querySelector("input[type=search]"),
alt_pressed = false;
document.addEventListener("keyup", (event) => {
if(event.keyCode !== 18) return;
alt_pressed = false;
console.info("[search box/alt-tracker] alt released");
});
document.addEventListener("keydown", (event) => {
if(event.keyCode !== 18) return;
alt_pressed = true;
console.info("[search box/alt-tracker] alt pressed");
});
search_box.form.addEventListener("submit", (event) => {
if(!alt_pressed) {
console.log("[search box/form] Alt wasn\'t pressed");
event.target.removeAttribute("target");
return;
}
console.log("[search box/form] Fiddling target");
event.target.setAttribute("target", "_blank");
setTimeout(() => {
alt_pressed = false;
}, 100);
});
});
');