action = $settings->defaultaction; /** The page name requested by the remote client. */ $env->page = ""; /** The filename that the page is stored in. */ $env->page_filename = ""; /** Whether we are looking at a history revision. */ $env->is_history_revision = false; /** An object holding history revision information for the current request */ $env->history = new stdClass(); /** The revision number requested of the current page */ $env->history->revision_number = -1; /** The revision data object from the page index for the requested revision */ $env->history->revision_data = false; /** The user's name if they are logged in. Defaults to `$settings->anonymous_user_name` if the user isn't currently logged in. @var string */ $env->user = $settings->anonymous_user_name; /** Whether the user is logged in */ $env->is_logged_in = false; /** Whether the user is an admin (moderator) @todo Refactor this to is_moderator, so that is_admin can be for the server owner. */ $env->is_admin = false; /** The currently logged in user's data. Please see $settings->users->username if you need to edit this - this is here for convenience :-) */ $env->user_data = new stdClass(); /** The data storage directory. Page filenames should be prefixed with this if you want their content. */ $env->storage_prefix = $settings->data_storage_dir . DIRECTORY_SEPARATOR; /** Contains performance data statistics for the current request. */ $env->perfdata = new stdClass(); /// Paths /// /** * Contains a bunch of useful paths to various important files. * None of these need to be prefixed with `$env->storage_prefix`. */ $paths = new stdClass(); /** The pageindex. Contains extensive information about all pages currently in this wiki. Individual entries for pages may be extended with arbitrary properties. */ $paths->pageindex = "pageindex.json"; /** The inverted index used for searching. Use the `search` class to interact with this - otherwise your brain might explode :P */ $paths->searchindex = "invindex.json"; /** The index that maps ids to page names. Use the `ids` class to interact with it :-) */ $paths->idindex = "idindex.json"; /** The cache of the most recently calculated statistics. */ $paths->statsindex = "statsindex.json"; // Prepend the storage data directory to all the defined paths. foreach ($paths as &$path) { $path = $env->storage_prefix . $path; } /** The master settings file @var string */ $paths->settings_file = $settingsFilename; /** The prefix to add to uploaded files */ $paths->upload_file_prefix = "Files/"; session_start(); // Make sure that the login cookie lasts beyond the end of the user's session setcookie(session_name(), session_id(), time() + $settings->sessionlifetime, "", "", false, true); ///////// Login System ///////// // Clear expired sessions if(isset($_SESSION[$settings->sessionprefix . "-expiretime"]) and $_SESSION[$settings->sessionprefix . "-expiretime"] < time()) { // Clear the session variables $_SESSION = []; session_destroy(); } if(isset($_SESSION[$settings->sessionprefix . "-user"]) and isset($_SESSION[$settings->sessionprefix . "-pass"])) { // Grab the session variables // Note that the 'pass' field here is actually a hash of the password set // by the login action $env->user = $_SESSION[$settings->sessionprefix . "-user"]; $env->pass = $_SESSION[$settings->sessionprefix . "-pass"]; if($settings->users->{$env->user}->password == $env->pass) { // The user is logged in $env->is_logged_in = true; $env->user_data = $settings->users->{$env->user}; } else { // The user's login details are invalid (what is going on here?) // Unset the session variables, treat them as an anonymous user, // and get out of here $env->is_logged_in = false; $env->user = $settings->anonymous_user_name; $env->pass = ""; // Clear the session data $_SESSION = []; // Delete all the variables session_destroy(); // Destroy the session } } // Check to see if the currently logged in user is an admin $env->is_admin = false; if($env->is_logged_in) { foreach($settings->admins as $admin_username) { if($admin_username == $env->user) { $env->is_admin = true; break; } } } /////// Login System End /////// //////////////////// // APIDoc strings // //////////////////// /** * @apiDefine Moderator Only users loggged with a moderator account may use this call. */ /** * @apiDefine User Only users loggged in may use this call. */ /** * @apiDefine Anonymous Anybody may use this call. */ /** * @apiDefine UserNotLoggedInError * @apiError UserNotLoggedInError You didn't log in before sending this request. */ /** * @apiDefine UserNotModeratorError * @apiError UserNotModeratorError You weren't loggged in as a moderator before sending this request. */ /** * @apiDefine PageParameter * @apiParam {string} page The page to operate on. */ //////////////////// /////////////////////////////////////////////////////////////////////////////// ////////////////////////////////// Functions ////////////////////////////////// /////////////////////////////////////////////////////////////////////////////// /** * Get the actual absolute origin of the request sent by the user. * @package core * @param array $s The $_SERVER variable contents. Defaults to $_SERVER. * @param bool $use_forwarded_host Whether to utilise the X-Forwarded-Host header when calculating the actual origin. * @return string The actual origin of the user's request. */ function url_origin( $s = false, $use_forwarded_host = false ) { if($s === false) $s = $_SERVER; $ssl = ( ! empty( $s['HTTPS'] ) && $s['HTTPS'] == 'on' ); $sp = strtolower( $s['SERVER_PROTOCOL'] ); $protocol = substr( $sp, 0, strpos( $sp, '/' ) ) . ( ( $ssl ) ? 's' : '' ); $port = $s['SERVER_PORT']; $port = ( ( ! $ssl && $port=='80' ) || ( $ssl && $port=='443' ) ) ? '' : ':'.$port; $host = ( $use_forwarded_host && isset( $s['HTTP_X_FORWARDED_HOST'] ) ) ? $s['HTTP_X_FORWARDED_HOST'] : ( isset( $s['HTTP_HOST'] ) ? $s['HTTP_HOST'] : null ); $host = isset( $host ) ? $host : $s['SERVER_NAME'] . $port; return $protocol . '://' . $host; } /** * Get the full url, as requested by the client. * @package core * @see http://stackoverflow.com/a/8891890/1460422 This Stackoverflow answer. * @param array $s The $_SERVER variable. Defaults to $_SERVER. * @param bool $use_forwarded_host Whether to take the X-Forwarded-Host header into account. * @return string The full url, as requested by the client. */ function full_url( $s = false, $use_forwarded_host = false ) { return url_origin( $s, $use_forwarded_host ) . $s['REQUEST_URI']; } /** * Converts a filesize into a human-readable string. * @package core * @see http://php.net/manual/en/function.filesize.php#106569 The original source * @author rommel * @author Edited by Starbeamrainbowlabs * @param number $bytes The number of bytes to convert. * @param number $decimals The number of decimal places to preserve. * @return string A human-readable filesize. */ function human_filesize($bytes, $decimals = 2) { $sz = ["b", "kb", "mb", "gb", "tb", "pb", "eb", "yb", "zb"]; $factor = floor((strlen($bytes) - 1) / 3); $result = round($bytes / pow(1024, $factor), $decimals); return $result . @$sz[$factor]; } /** * Calculates the time since a particular timestamp and returns a * human-readable result. * @package core * @see http://goo.gl/zpgLgq The original source. No longer exists, maybe the wayback machine caught it :-( * @param integer $time The timestamp to convert. * @return string The time since the given timestamp as * a human-readable string. */ function human_time_since($time) { return human_time(time() - $time); } /** * Renders a given number of seconds as something that humans can understand more easily. * @package core * @param int $seconds The number of seconds to render. * @return string The rendered time. */ function human_time($seconds) { $tokens = array ( 31536000 => 'year', 2592000 => 'month', 604800 => 'week', 86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second' ); foreach ($tokens as $unit => $text) { if ($seconds < $unit) continue; $numberOfUnits = floor($seconds / $unit); return $numberOfUnits.' '.$text.(($numberOfUnits>1)?'s':'').' ago'; } } /** * A recursive glob() function. * @package core * @see http://in.php.net/manual/en/function.glob.php#106595 The original source * @author Mike * @param string $pattern The glob pattern to use to find filenames. * @param integer $flags The glob flags to use when finding filenames. * @return array An array of the filepaths that match the given glob. */ function glob_recursive($pattern, $flags = 0) { $files = glob($pattern, $flags); foreach (glob(dirname($pattern).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) { $prefix = "$dir/"; // Remove the "./" from the beginning if it exists if(substr($prefix, 0, 2) == "./") $prefix = substr($prefix, 2); $files = array_merge($files, glob_recursive($prefix . basename($pattern), $flags)); } return $files; } /** * Gets the name of the parent page to the specified page. * @since 0.15 * @package core * @param string $pagename The child page to get the parent * page name for. * @return string|bool */ function get_page_parent($pagename) { if(mb_strpos($pagename, "/") === false) return false; return mb_substr($pagename, 0, mb_strrpos($pagename, "/")); } /** * Gets a list of all the sub pages of the current page. * @package core * @param object $pageindex The pageindex to use to search. * @param string $pagename The name of the page to list the sub pages of. * @return object An object containing all the subpages and their * respective distances from the given page name in the pageindex tree. */ function get_subpages($pageindex, $pagename) { $pagenames = get_object_vars($pageindex); $result = new stdClass(); $stem = "$pagename/"; $stem_length = strlen($stem); foreach($pagenames as $entry => $value) { if(substr($entry, 0, $stem_length) == $stem) { // We found a subpage // Extract the subpage's key relative to the page that we are searching for $subpage_relative_key = substr($entry, $stem_length, -3); // Calculate how many times removed the current subpage is from the current page. 0 = direct descendant. $times_removed = substr_count($subpage_relative_key, "/"); // Store the name of the subpage we found $result->$entry = $times_removed; } } unset($pagenames); return $result; } /** * Makes sure that a subpage's parents exist. * Note this doesn't check the pagename itself. * @package core * @param $pagename The pagename to check. */ function check_subpage_parents($pagename) { global $pageindex, $paths, $env; // Save the new pageindex and return if there aren't any more parent pages to check if(strpos($pagename, "/") === false) { file_put_contents($paths->pageindex, json_encode($pageindex, JSON_PRETTY_PRINT)); return; } $parent_pagename = substr($pagename, 0, strrpos($pagename, "/")); $parent_page_filename = "$parent_pagename.md"; if(!file_exists($env->storage_prefix . $parent_page_filename)) { // This parent page doesn't exist! Create it and add it to the page index. touch($env->storage_prefix . $parent_page_filename, 0); $newentry = new stdClass(); $newentry->filename = $parent_page_filename; $newentry->size = 0; $newentry->lastmodified = 0; $newentry->lasteditor = "none"; $pageindex->$parent_pagename = $newentry; } check_subpage_parents($parent_pagename); } /** * Makes a path safe. * Paths may only contain alphanumeric characters, spaces, underscores, and * dashes. * @package core * @param string $string The string to make safe. * @return string A safe version of the given string. */ function makepathsafe($string) { // Old restrictive system //$string = preg_replace("/[^0-9a-zA-Z\_\-\ \/\.]/i", "", $string); // Remove reserved characters $string = preg_replace("/[?%*:|\"><()\\[\\]]/i", "", $string); // Collapse multiple dots into a single dot $string = preg_replace("/\.+/", ".", $string); // Don't allow slashes at the beginning $string = ltrim($string, "\\/"); return $string; } /** * Hides an email address from bots by adding random html entities. * @todo Make this moree clevererer :D * @package core * @param string $str The original email address * @return string The mangled email address. */ function hide_email($str) { $hidden_email = ""; for($i = 0; $i < strlen($str); $i++) { if($str[$i] == "@") { $hidden_email .= "&#" . ord("@") . ";"; continue; } if(rand(0, 1) == 0) $hidden_email .= $str[$i]; else $hidden_email .= "&#" . ord($str[$i]) . ";"; } return $hidden_email; } /** * Checks to see if $haystack starts with $needle. * @package core * @param string $haystack The string to search. * @param string $needle The string to search for at the beginning * of $haystack. * @return boolean Whether $needle can be found at the beginning * of $haystack. */ function starts_with($haystack, $needle) { $length = strlen($needle); return (substr($haystack, 0, $length) === $needle); } /** * Case-insensitively finds all occurrences of $needle in $haystack. Handles * UTF-8 characters correctly. * @package core * @see http://www.pontikis.net/tip/?id=16 the source * @see http://www.php.net/manual/en/function.strpos.php#87061 the source that the above was based on * @param string $haystack The string to search. * @param string $needle The string to find. * @return array|false An array of match indices, or false if * nothing was found. */ function mb_stripos_all($haystack, $needle) { $s = 0; $i = 0; while(is_integer($i)) { $i = function_exists("mb_stripos") ? mb_stripos($haystack, $needle, $s) : stripos($haystack, $needle, $s); if(is_integer($i)) { $aStrPos[] = $i; $s = $i + (function_exists("mb_strlen") ? mb_strlen($needle) : strlen($needle)); } } if(isset($aStrPos)) return $aStrPos; else return false; } /** * Tests whether a string starts with a specified substring. * @package core * @param string $haystack The string to check against. * @param string $needle The substring to look for. * @return bool Whether the string starts with the specified substring. */ function startsWith($haystack, $needle) { return $needle === "" || strrpos($haystack, $needle, -strlen($haystack)) !== false; } /** * Tests whether a string ends with a given substring. * @package core * @param string $whole The string to test against. * @param string $end The substring test for. * @return bool Whether $whole ends in $end. */ function endsWith($whole, $end) { return (strpos($whole, $end, strlen($whole) - strlen($end)) !== false); } /** * Replaces the first occurrence of $find with $replace. * @package core * @param string $find The string to search for. * @param string $replace The string to replace the search string with. * @param string $subject The string ot perform the search and replace on. * @return string The source string after the find and replace has been performed. */ function str_replace_once($find, $replace, $subject) { $index = strpos($subject, $find); if($index !== false) return substr_replace($subject, $replace, $index, strlen($find)); return $subject; } /** * Returns the system's mime type mappings, considering the first extension * listed to be cacnonical. * @package core * @see http://stackoverflow.com/a/1147952/1460422 From this stackoverflow answer * @author chaos * @author Edited by Starbeamrainbowlabs * @return array An array of mime type mappings. */ function system_mime_type_extensions() { global $settings; $out = array(); $file = fopen($settings->mime_extension_mappings_location, 'r'); while(($line = fgets($file)) !== false) { $line = trim(preg_replace('/#.*/', '', $line)); if(!$line) continue; $parts = preg_split('/\s+/', $line); if(count($parts) == 1) continue; $type = array_shift($parts); if(!isset($out[$type])) $out[$type] = array_shift($parts); } fclose($file); return $out; } /** * Converts a given mime type to it's associated file extension. * @package core * @see http://stackoverflow.com/a/1147952/1460422 From this stackoverflow answer * @author chaos * @author Edited by Starbeamrainbowlabs * @param string $type The mime type to convert. * @return string The extension for the given mime type. */ function system_mime_type_extension($type) { static $exts; if(!isset($exts)) $exts = system_mime_type_extensions(); return isset($exts[$type]) ? $exts[$type] : null; } /** * Returns the system MIME type mapping of extensions to MIME types. * @package core * @see http://stackoverflow.com/a/1147952/1460422 From this stackoverflow answer * @author chaos * @author Edited by Starbeamrainbowlabs * @return array An array mapping file extensions to their associated mime types. */ function system_extension_mime_types() { global $settings; $out = array(); $file = fopen($settings->mime_extension_mappings_location, 'r'); while(($line = fgets($file)) !== false) { $line = trim(preg_replace('/#.*/', '', $line)); if(!$line) continue; $parts = preg_split('/\s+/', $line); if(count($parts) == 1) continue; $type = array_shift($parts); foreach($parts as $part) $out[$part] = $type; } fclose($file); return $out; } /** * Converts a given file extension to it's associated mime type. * @package core * @see http://stackoverflow.com/a/1147952/1460422 From this stackoverflow answer * @author chaos * @author Edited by Starbeamrainbowlabs * @param string $ext The extension to convert. * @return string The mime type associated with the given extension. */ function system_extension_mime_type($ext) { static $types; if(!isset($types)) $types = system_extension_mime_types(); $ext = strtolower($ext); return isset($types[$ext]) ? $types[$ext] : null; } /** * Figures out whether a given http accepts header contains a * specified mime type. * @package core * @param string $accept_header The accept header to search. * @param string $mime_type The mime type to search for. * @return bool Whether the specified mime type was found * in the specified accepts header. */ function accept_contains_mime($accept_header, $mime_type) { $accepted_mimes = explode(",", $accept_header); foreach($accepted_mimes as $accepted_mime) { if(explode(";", $accepted_mime)[0] == $mime_type) return true; } return false; } /** * Generates a stack trace. * @package core * @param bool $log_trace Whether to send the stack trace to the error log. * @param bool $full Whether to output a full description of all the variables involved. * @return string A string prepresentation of a stack trace. */ function stack_trace($log_trace = true, $full = false) { $result = ""; $stackTrace = debug_backtrace(); $stackHeight = count($stackTrace); foreach ($stackTrace as $i => $stackEntry) { $result .= "#" . ($stackHeight - $i) . ": "; $result .= (isset($stackEntry["file"]) ? $stackEntry["file"] : "(unknown file)") . ":" . (isset($stackEntry["line"]) ? $stackEntry["line"] : "(unknown line)") . " - "; if(isset($stackEntry["function"])) { $result .= "(calling " . $stackEntry["function"]; if(isset($stackEntry["args"]) && count($stackEntry["args"])) { $result .= ": "; $result .= implode(", ", array_map($full ? "var_dump_ret" : "var_dump_short", $stackEntry["args"])); } } $result .= ")\n"; } if($log_trace) error_log($result); return $result; } /** * Calls var_dump() and returns the output. * @package core * @param mixed $var The thing to pass to var_dump(). * @return string The output captured from var_dump(). */ function var_dump_ret($var) { ob_start(); var_dump($var); return ob_get_clean(); } /** * Calls var_dump(), shortening the output for various types. * @package core * @param mixed $var The thing to pass to var_dump(). * @return string A shortened version of the var_dump() output. */ function var_dump_short($var) { $result = trim(var_dump_ret($var)); if(substr($result, 0, 6) === "object" || substr($result, 0, 5) === "array") { $result = substr($result, 0, strpos($result, " ")) . " { ... }"; } return $result; } if (!function_exists('getallheaders')) { /** * Polyfill for PHP's native getallheaders() function on platforms that * don't have it. * @package core * @todo Identify which platforms don't have it and whether we still need this */ function getallheaders() { if (!is_array($_SERVER)) return []; $headers = array(); foreach ($_SERVER as $name => $value) { if (substr($name, 0, 5) == 'HTTP_') { $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value; } } return $headers; } } /** * Renders a timestamp in HTML. * @package core * @param int $timestamp The timestamp to render. * @return string HTML representing the given timestamp. */ function render_timestamp($timestamp) { return ""; } /** * Renders a page name in HTML. * @package core * @param object $rchange The recent change to render as a page name * @return string HTML representing the name of the given page. */ function render_pagename($rchange) { global $pageindex; $pageDisplayName = $rchange->page; if(isset($pageindex->$pageDisplayName) and !empty($pageindex->$pageDisplayName->redirect)) $pageDisplayName = "$pageDisplayName"; $pageDisplayLink = "$pageDisplayName"; return $pageDisplayName; } /** * Renders an editor's or a group of editors name(s) in HTML. * @package core * @param string $editorName The name of the editor to render. * @return string HTML representing the given editor's name. */ function render_editor($editorName) { return "✎ $editorName"; } /** * Saves the currently logged in uesr's data back to peppermint.json. * @package core * @return bool Whether the user's data was saved successfully. Returns false if the user isn't logged in. */ function save_userdata() { global $env, $settings, $paths; if(!$env->is_logged_in) return false; $settings->users->{$env->user} = $env->user_data; file_put_contents($paths->settings_file, json_encode($settings, JSON_PRETTY_PRINT)); return true; } /** * Figures out the path to the user page for a given username. * Does not check to make sure the user acutally exists. * @package core * @param string $username The username to get the path to their user page for. * @return string The path to the given user's page. */ function get_user_pagename($username) { global $settings; return "$settings->user_page_prefix/$username"; } /** * Extracts a username from a user page path. * @package core * @param string $userPagename The suer page path to extract from. * @return string The name of the user that the user page belongs to. */ function extract_user_from_userpage($userPagename) { global $settings; $matches = []; preg_match("/$settings->user_page_prefix\\/([^\\/]+)\\/?/", $userPagename, $matches); return $matches[1]; } /** * Sends a plain text email to a user, replacing {username} with the specified username. * @package core * @param string $username The username to send the email to. * @param string $subject The subject of the email. * @param string $body The body of the email. * @return boolean Whether the email was sent successfully or not. Currently, this may fail if the user doesn't have a registered email address. */ function email_user($username, $subject, $body) { global $version, $settings; // If the user doesn't have an email address, then we can't email them :P if(empty($settings->users->{$username}->emailAddress)) return false; $subject = str_replace("{username}", $username, $subject); $body = str_replace("{username}", $username, $body); $headers = [ "content-type" => "text/plain", "x-mailer" => "$settings->sitename Pepperminty-Wiki/$version PHP/" . phpversion(), "reply-to" => "$settings->admindetails_name <$settings->admindetails_email>" ]; $compiled_headers = ""; foreach($headers as $header => $value) $compiled_headers .= "$header: $value\r\n"; mail($settings->users->{$username}->emailAddress, $subject, $body, $compiled_headers, "-t"); return true; } /** * Sends a plain text email to a list of users, replacing {username} with each user's name. * @package core * @param string[] $usernames A list of usernames to email. * @param string $subject The subject of the email. * @param string $body The body of the email. * @return integer The number of emails sent successfully. */ function email_users($usernames, $subject, $body) { $emailsSent = 0; foreach($usernames as $username) { $emailsSent += email_user($username, $subject, $body) ? 1 : 0; } return $emailsSent; } /////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////// ////////////////////// Security and Consistency Measures ////////////////////// /////////////////////////////////////////////////////////////////////////////// /* * Sort out the pageindex. Create it if it doesn't exist, and load + parse it * if it does. */ if(!file_exists($paths->pageindex)) { $glob_str = $env->storage_prefix . "*.md"; $existingpages = glob_recursive($glob_str); // Debug statements. Uncomment when debugging the pageindex regenerator. // var_dump($env->storage_prefix); // var_dump($glob_str); // var_dump($existingpages); $pageindex = new stdClass(); // We use a for loop here because foreach doesn't loop over new values inserted // while we were looping for($i = 0; $i < count($existingpages); $i++) { $pagefilename = $existingpages[$i]; // Create a new entry $newentry = new stdClass(); $newentry->filename = utf8_encode(substr( // Store the filename, whilst trimming the storage prefix $pagefilename, strlen(preg_replace("/^\.\//i", "", $env->storage_prefix)) // glob_recursive trim the ./ from returned filenames , so we need to as well )); // Remove the `./` from the beginning if it's still hanging around if(substr($newentry->filename, 0, 2) == "./") $newentry->filename = substr($newentry->filename, 2); $newentry->size = filesize($pagefilename); // Store the page size $newentry->lastmodified = filemtime($pagefilename); // Store the date last modified // Todo find a way to keep the last editor independent of the page index $newentry->lasteditor = utf8_encode("unknown"); // Set the editor to "unknown" // Extract the name of the (sub)page without the ".md" $pagekey = utf8_encode(substr($newentry->filename, 0, -3)); if(file_exists($env->storage_prefix . $pagekey) && // If it exists... !is_dir($env->storage_prefix . $pagekey)) // ...and isn't a directory { // This page (potentially) has an associated file! // Let's investigate. // Blindly add the file to the pageindex for now. // Future We might want to do a security check on the file later on. // File a bug if you think we should do this. $newentry->uploadedfile = true; // Yes this page does have an uploaded file associated with it $newentry->uploadedfilepath = $pagekey; // It's stored here // Work out what kind of file it really is $mimechecker = finfo_open(FILEINFO_MIME_TYPE); $newentry->uploadedfilemime = finfo_file($mimechecker, $env->storage_prefix . $pagekey); } // Debug statements. Uncomment when debugging the pageindex regenerator. // echo("pagekey: "); // var_dump($pagekey); // echo("newentry: "); // var_dump($newentry); // Subpage parent checker if(strpos($pagekey, "/") !== false) { // We have a sub page people // Work out what our direct parent's key must be in order to check to // make sure that it actually exists. If it doesn't, then we need to // create it. $subpage_parent_key = substr($pagekey, 0, strrpos($pagekey, "/")); $subpage_parent_filename = "$env->storage_prefix$subpage_parent_key.md"; if(array_search($subpage_parent_filename, $existingpages) === false) { // Our parent page doesn't actually exist - create it touch($subpage_parent_filename, 0); // Furthermore, we should add this page to the list of existing pages // in order for it to be indexed $existingpages[] = $subpage_parent_filename; } } // Store the new entry in the new page index $pageindex->$pagekey = $newentry; } file_put_contents($paths->pageindex, json_encode($pageindex, JSON_PRETTY_PRINT)); unset($existingpages); } else { $pageindex_read_start = microtime(true); $pageindex = json_decode(file_get_contents($paths->pageindex)); $env->perfdata->pageindex_decode_time = round((microtime(true) - $pageindex_read_start)*1000, 3); header("x-pageindex-decode-time: " . $env->perfdata->pageindex_decode_time . "ms"); } ////////////////////////// ///// Page id system ///// ////////////////////////// if(!file_exists($paths->idindex)) file_put_contents($paths->idindex, "{}"); $idindex_decode_start = microtime(true); $idindex = json_decode(file_get_contents($paths->idindex)); $env->perfdata->idindex_decode_time = round((microtime(true) - $idindex_decode_start)*1000, 3); /** * Provides an interface to interact with page ids. * @package core */ class ids { /** * Gets the page id associated with the given page name. * If it doesn't exist in the id index, it will be added. * @package core * @param string $pagename The name of the page to fetch the id for. * @return integer The id for the specified page name. */ public static function getid($pagename) { global $idindex; foreach ($idindex as $id => $entry) { if($entry == $pagename) return $id; } // This pagename doesn't have an id - assign it one quick! return self::assign($pagename); } /** * Gets the page name associated with the given page id. * Be warned that if the id index is cleared (e.g. when the search index is * rebuilt from scratch), the id associated with a page name may change! * @package core * @param int $id The id to fetch the page name for. * @return string The page name currently associated with the specified id. */ public static function getpagename($id) { global $idindex; if(!isset($idindex->$id)) return false; else return $idindex->$id; } /** * Moves a page in the id index from $oldpagename to $newpagename. * Note that this function doesn't perform any special checks to make sure * that the destination name doesn't already exist. * @package core * @param string $oldpagename The old page name to move. * @param string $newpagename The new pagee name to move the old page name to. */ public static function movepagename($oldpagename, $newpagename) { global $idindex, $paths; $pageid = self::getid($oldpagename); $idindex->$pageid = $newpagename; file_put_contents($paths->idindex, json_encode($idindex)); } /** * Removes the given page name from the id index. * Note that this function doesn't handle multiple entries with the same * name. Also note that it may get re-added during a search reindex if the * page still exists. * @package core * @param string $pagename The page name to delete from the id index. */ public static function deletepagename($pagename) { global $idindex, $paths; // Get the id of the specified page $pageid = self::getid($pagename); // Remove it from the pageindex unset($idindex->$pageid); // Save the id index file_put_contents($paths->idindex, json_encode($idindex)); } /** * Clears the id index completely. * Will break the inverted search index! Make sure you rebuild the search * index (if the search module is installed, of course) if you want search * to still work. Of course, note that will re-add all the pages to the id * index. * @package core */ public static function clear() { global $paths, $idindex; // Delete the old id index unlink($paths->idindex); // Create the new id index file_put_contents($paths->idindex, "{}"); // Reset the in-memory id index $idindex = new stdClass(); } /** * Assigns an id to a pagename. Doesn't check to make sure that * pagename doesn't already exist in the id index. * @package core * @param string $pagename The page name to assign an id to. * @return integer The id assigned to the specified page name. */ protected static function assign($pagename) { global $idindex, $paths; $nextid = count(array_keys(get_object_vars($idindex))); // Increment the generated id until it's unique while(isset($idindex->nextid)) $nextid++; // Update the id index $idindex->$nextid = utf8_encode($pagename); // Save the id index file_put_contents($paths->idindex, json_encode($idindex)); return $nextid; } } ////////////////////////// ////////////////////////// // Work around an Opera + Syntaxtic bug where there is no margin at the left // hand side if there isn't a query string when accessing a .php file. if(!isset($_GET["action"]) and !isset($_GET["page"]) and basename(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)) == "index.php") { http_response_code(302); header("location: " . dirname(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH))); exit(); } // Make sure that the action is set if(empty($_GET["action"])) $_GET["action"] = $settings->defaultaction; // Make sure that the page is set if(empty($_GET["page"]) or strlen($_GET["page"]) === 0) $_GET["page"] = $settings->defaultpage; // Redirect the user to the safe version of the path if they entered an unsafe character if(makepathsafe($_GET["page"]) !== $_GET["page"]) { http_response_code(301); header("location: index.php?action=" . rawurlencode($_GET["action"]) . "&page=" . makepathsafe($_GET["page"])); header("x-requested-page: " . $_GET["page"]); header("x-actual-page: " . makepathsafe($_GET["page"])); exit(); } //////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// //////////////////////////////// HTML fragments //////////////////////////////// //////////////////////////////////////////////////////////////////////////////// /** * 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 = " {title} {header-html} {body} "; /** * The main content template that is used to render normal wiki pages. * @var string * @package core */ public static $main_content_template = "{navigation-bar}

{sitename}

{content}
{extra} {navigation-bar-bottom} {all-pages-datalist}"; /** * A specially minified content template that doesn't include the navbar and * other elements not suiltable for printing. * @var string * @package core */ public static $minimal_content_template = "
{content}
"; /** * 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 function $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); $admin_name = $settings->admindetails_name; $admin_email = hide_email($settings->admindetails_email); exit(page_renderer::render("$settings->sitename - Module Error", "

$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 $admin_name at $admin_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 boolean $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, $start_time, $version; if($body_template === false) $body_template = self::$main_content_template; if(strlen($settings->logo_url) > 0) { // A logo url has been specified $logo_html = ""; 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."); } } $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-name}" => $settings->admindetails_name, "{admin-details-email}" => $settings->admindetails_email, "{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}" => $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); 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); } /** * 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::get_css_as_html(); $result .= self::getJS(); // 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\n"; if(!empty($settings->enable_math_rendering)) { $result .= ""; } return $result; } /** * 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; if(preg_match("/^[^\/]*\/\/|^\//", $settings->css)) return "\n"; else { $css = $settings->css; if(!empty($settings->optimize_pages)) { // CSS Minification ideas by Jean from catswhocode.com // Link: http://www.catswhocode.com/blog/3-ways-to-compress-css-files-using-php // Remove comments $css = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!', "", $css); // Cut down whitespace $css = preg_replace('/\s+/', " ", $css); // Remove whitespace after colons and semicolons $css = str_replace([ " :", ": ", "; ", " { ", " } " ], [ ":", ":", ";", "{", "}" ], $css); } return "\n"; } } /** * 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 = []; /** * 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 function AddJSLink(string $scriptUrl) { static::$jsLinks[] = $scriptUrl; } /** * Adds a javascript snippet to the page. * @package core * @param string $script The snippet of javascript to add. */ public function AddJSSnippet(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 getJS() { $result = "\n"; foreach(static::$jsSnippets as $snippet) $result .= "\n"; foreach(static::$jsLinks as $link) $result .= "\n"; return $result; } // ~ /** * The navigation bar divider. * @package core * @var string */ public static $nav_divider = " | "; /** * 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; $result = "

"; 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 .= ""; if($settings->avatars_show) $result .= " "; if(in_array($name, $settings->admins)) $result .= $settings->admindisplaychar; $result .= htmlentities($name); $result .= ""; 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; $arrayPageIndex = get_object_vars($pageindex); ksort($arrayPageIndex); $result = "\n"; // If dynamic page sugggestions are enabled, then we should send a loading message instead. if($settings->dynamic_page_suggestion_count > 0) { $result .= ""; return $result; } } // Math rendering support if(!empty($settings->enable_math_rendering)) { page_renderer::AddJSLink("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::AddJSSnippet('// Alt + Enter support in the top search box window.addEventListener("load", function(event) { document.querySelector("input[type=search]").addEventListener("keyup", function(event) { // Listen for Alt + Enter if(event.keyCode == 13 && event.altKey) { event.stopPropagation(); event.preventDefault(); event.cancelBubble = true; event.target.form.setAttribute("target", "_blank"); event.target.form.submit(); event.target.form.removeAttribute("target"); } }); }); '); /// Finish setting up the environment object /// $env->page = $_GET["page"]; if(isset($_GET["revision"]) and is_numeric($_GET["revision"])) { // We have a revision number! $env->is_history_revision = true; $env->history->revision_number = intval($_GET["revision"]); // Make sure that the revision exists for later on if(!isset($pageindex->{$env->page}->history[$env->history->revision_number])) { http_response_code(404); exit(page_renderer::render_main("404: Revision Not Found - $env->page - $settings->sitename", "

Revision #{$env->history->revision_number} of $env->page doesn't appear to exist. Try viewing the list of revisions for $env->page, or viewing the latest revision instead.

")); } $env->history->revision_data = $pageindex->{$env->page}->history[$env->history->revision_number]; } // Construct the page's filename $env->page_filename = $env->storage_prefix; if($env->is_history_revision) $env->page_filename .= $pageindex->{$env->page}->history[$env->history->revision_number]->filename; else if(isset($pageindex->{$env->page})) $env->page_filename .= $pageindex->{$env->page}->filename; $env->action = strtolower($_GET["action"]); //////////////////////////////////////////////// ////////////////////////////////////// ///// Extra consistency measures ///// ////////////////////////////////////// // Redirect to the search page if there isn't a page with the requested name if(!isset($pageindex->{$env->page}) and isset($_GET["search-redirect"])) { http_response_code(307); $url = "?action=search&query=" . rawurlencode($env->page); header("location: $url"); exit(page_renderer::render("Non existent page - $settings->sitename", "

There isn't a page on $settings->sitename with that name. However, you could search for this page name in other pages.

Alternatively, you could create this page.

")); } // Redirect the user to the login page if: // - A login is required to view this wiki // - The user isn't already requesting the login page // Note we use $_GET here because $env->action isn't populated at this point if($settings->require_login_view === true && // If this site requires a login in order to view pages !$env->is_logged_in && // And the user isn't logged in !in_array($_GET["action"], [ "login", "checklogin", "opensearch-description", "invindex-rebuild", "stats-update" ])) // And the user isn't trying to login, or get the opensearch description, or access actions that apply their own access rules { // Redirect the user to the login page http_response_code(307); $url = "?action=login&returnto=" . rawurlencode($_SERVER["REQUEST_URI"]) . "&required=true"; header("location: $url"); exit(page_renderer::render("Login required - $settings->sitename", "

$settings->sitename requires that you login before you are able to access it.

Login.

")); } ////////////////////////////////////// ////////////////////////////////////// $remote_files = []; /** * Registers a request for a remote file to be downloaded before execution. Will block until all files are downloaded. * Example definition: * [ "local_filename" => "file.ext", "remote_url": "https://example.com" ] * @param array $remote_file_def The remote file definition to register. * @throws Exception Exception Throws an exception if a definition for the requested local file already exists. */ function register_remote_file($remote_file_def) { global $remote_files; foreach($remote_files as $ex_remote_file_def) { if($ex_remote_file_def["local_filename"] == $remote_file_def["local_filename"]) throw new Exception("Error: A remote file with the local filename '{$remote_file_def["local_filename"]}' is already registered."); } $remote_files[] = $remote_file_def; } ////////////////////////// /// Module functions /// ////////////////////////// // These functions are // // used by modules to // // register themselves // // or new pages. // ////////////////////////// /** A list of all the currently loaded modules. Not guaranteed to be populated until an action is executed. */ $modules = []; /** * Registers a module. * @package core * @param array $moduledata The module data to register. */ function register_module($moduledata) { global $modules; //echo("registering module\n"); //var_dump($moduledata); $modules[] = $moduledata; } /** * Checks to see whether a module with the given id exists. * @package core * @param string $id The id to search for. * @return bool Whether a module is currently loaded with the given id. */ function module_exists($id) { global $modules; foreach($modules as $module) { if($module["id"] == $id) return true; } return false; } $actions = new stdClass(); /** * Registers a new action handler. * @package core * @param string $action_name The action to register. * @param function $func The function to call when the specified * action is requested. */ function add_action($action_name, $func) { global $actions; $actions->$action_name = $func; } /** * Figures out whether a given action is currently registered. * Only guaranteed to be accurate in inside an existing action function * @package core * @param string $action_name The name of the action to search for * @return boolean Whether an action with the specified name exists. */ function has_action($action_name) { global $actions; return !empty($actions->$action_name); } $parsers = [ "none" => function() { throw new Exception("No parser registered!"); } ]; /** * Registers a new parser. * @package core * @param string $name The name of the new parser to register. * @param function $parser_code The function to register as a new parser. */ function add_parser($name, $parser_code) { global $parsers; if(isset($parsers[$name])) throw new Exception("Can't register parser with name '$name' because a parser with that name already exists."); $parsers[$name] = $parser_code; } /** * Parses the specified page source using the parser specified in the settings * into HTML. * The specified parser may (though it's unilkely) render it to other things. * @package core * @param string $source The source to render. * @return string The source rendered to HTML. */ function parse_page_source($source) { global $settings, $parsers; if(!isset($parsers[$settings->parser])) exit(page_renderer::render_main("Parsing error - $settings->sitename", "

Parsing some page source data failed. This is most likely because $settings->sitename has the parser setting set incorrectly. Please contact " . $settings->admindetails_name . ", your $settings->sitename Administrator.")); /* Not needed atm because escaping happens when saving, not when rendering * if($settings->clean_raw_html) $source = htmlentities($source, ENT_QUOTES | ENT_HTML5); */ return $parsers[$settings->parser]($source); } // Function to $save_preprocessors = []; /** * Register a new proprocessor that will be executed just before * an edit is saved. * @package core * @param function $func The function to register. */ function register_save_preprocessor($func) { global $save_preprocessors; $save_preprocessors[] = $func; } $help_sections = []; /** * Adds a new help section to the help page. * @package core * @param string $index The string to index the new section under. * @param string $title The title to display above the section. * @param string $content The content to display. */ function add_help_section($index, $title, $content) { global $help_sections; $help_sections[$index] = [ "title" => $title, "content" => $content ]; } if(!empty($settings->enable_math_rendering)) add_help_section("22-mathematical-mxpressions", "Mathematical Expressions", "

$settings->sitename supports rendering of mathematical expressions. Mathematical expressions can be included practically anywhere in your page. Expressions should be written in LaTeX and enclosed in dollar signs like this: $x^2$.

Note that expression parsing is done on the viewer's computer with javascript (specifically MathJax) and not by $settings->sitename directly (also called client side rendering).

"); /** An array of the currently registerd statistic calculators. Not guaranteed to be populated until the requested action function is called. */ $statistic_calculators = []; /** * Registers a statistic calculator against the system. * @package core * @param array $stat_data The statistic object to register. */ function statistic_add($stat_data) { global $statistic_calculators; $statistic_calculators[$stat_data["id"]] = $stat_data; } /** * Checks whether a specified statistic has been registered. * @package core * @param string $stat_id The id of the statistic to check the existence of. * @return boolean Whether the specified statistic has been registered. */ function has_statistic($stat_id) { global $statistic_calculators; return !empty($statistic_calculators[$stat_id]); } ////////////////////////////////////////////////////////////////// // %next_module% // ////////////////////////////////////////////////////////////////// // Execute each module's code foreach($modules as $moduledata) { $moduledata["code"](); } // Make sure that the credits page exists if(!isset($actions->credits)) { exit(page_renderer::render_main("Error - $settings->$sitename", "

No credits page detected. The credits page is a required module!

")); } // Download all the requested remote files ini_set("user_agent", "$settings->sitename (Pepperminty-Wiki-Downloader; PHP/" . phpversion() . "; +https://github.com/sbrl/Pepperminty-Wiki/) Pepperminty-Wiki/$version"); foreach($remote_files as $remote_file_def) { if(file_exists($remote_file_def["local_filename"]) && filesize($remote_file_def["local_filename"]) > 0) continue; error_log("[ Pepperminty-Wiki/$settings->sitename ] Downloading {$remote_file_def["local_filename"]} from {$remote_file_def["remote_url"]}"); file_put_contents($remote_file_def["local_filename"], fopen($remote_file_def["remote_url"], "rb")); } // Perform the appropriate action $action_name = $env->action; if(isset($actions->$action_name)) { $req_action_data = $actions->$action_name; $req_action_data(); } else { exit(page_renderer::render_main("Error - $settings->sitename", "

No action called " . strtolower($_GET["action"]) ." has been registered. Perhaps you are missing a module?

")); } ?>