* Rendering: MathJax (https://www.mathjax.org/)
* Bug reports:
* #2 - Incorrect closing tag - nibreh
\nBy submitting your edit or uploading your file, you are agreeing to release your changes under this license. Also note that if you don't want your work to be edited by other users of this site, please don't submit it here!" },
"admindisplaychar": { "type": "text", "description": "The string that is prepended before an admin's name on the nav bar. Defaults to a diamond shape (◆).", "default": "◆" },
"protectedpagechar": { "type": "text", "description": "The string that is prepended a page's name in the page title if it is protected. Defaults to a lock symbol. (🔒)", "default": "🔒" },
"editing": { "type": "checkbox", "description": "Whether editing is enabled.", "default": true},
"anonedits": { "type": "checkbox", "description": "Whether users who aren't logged in are allowed to edit your wiki.", "default": false },
"maxpagesize": { "type": "number", "description": "The maximum page size in characters.", "default": 135000 },
"parser": { "type": "text", "description": "The parser to use when rendering pages. Defaults to an extended version of parsedown (http://parsedown.org/)", "default": "parsedown" },
"parser_cache": { "type": "checkbox", "description": "Whether parser output should be cached to speed things up. The cache directory is ._cache
in the data directory - delete it if you experience issues (unlikely).", "default": true },
"parser_cache_min_size": { "type": "number", "description": "The minimum size a source string must be (in bytes) before it's considered eligible for caching.", "default": 1024 },
"interwiki_index_location": { "type": "text", "description": "The location to find the interwiki wiki definition file, which contains a list of wikis along with their names, prefixes, and root urls. May be a URL, or simply a file path - as it's passed to file_get_contents(). If left blank, interwiki link parsing is disabled.", "default": null },
"clean_raw_html": { "type": "checkbox", "description": "Whether page sources should be cleaned of HTML before rendering. It is STRONGLY recommended that you keep this option turned on.", "default": true},
"enable_math_rendering": { "type": "checkbox", "description": "Whether to enable client side rendering of mathematical expressions with MathJax (https://www.mathjax.org/). Math expressions should be enclosed inside of dollar signs ($). Turn off if you don't use it.", "default": true},
"users": { "type": "usertable", "description": "An array of usernames and passwords - passwords should be hashed with password_hash() (the hash action can help here)", "default": {
"admin": {
"email": "admin@somewhere.com",
"password": "$2y$10$kX6QgET6SfL47GsJjxwp/.JE6SSJo4Nx8/wG13eNvLDGIduYTlCXO"
},
"user": {
"email": "example@example.net",
"password": "$2y$10$tWYjgh5WvaJrwiszZ1e2Keo3ras6mqa4ptqruwUn3de4UB6eV9cnW"
}
}},
"admins": { "type": "array", "description": "An array of usernames that are administrators. Administrators can delete and move pages.", "default": [ "admin" ]},
"anonymous_user_name": { "type": "text", "description": "The default name for anonymous users.", "default": "Anonymous" },
"user_page_prefix": { "type": "text", "description": "The prefix for user pages. All user pages will be considered to be under this page. User pages have special editing restrictions that prevent anyone other thant he user they belong to from editing them. Should not include the trailing forward slash.", "default": "Users" },
"user_preferences_button_text": { "type": "text", "description": "The text to display on the button that lets logged in users change their settings. Defaults to a cog (aka a 'gear' in unicode-land).", "default": "⚙ " },
"password_algorithm": { "type": "text", "description": "The algorithm to utilise when hashing passwords. Takes any value PHP's password_hash() does.", "default": "PASSWORD_DEFAULT" },
"password_cost": { "type": "number", "description": "The cost to use when hashing passwords.", "default": 12},
"password_cost_time": { "type": "number", "description": "The desired number of milliseconds to delay by when hashing passwords. Pepperminty Wiki will automatically update the value of password_cost to take the length of time specified here. If you're using PASSWORD_ARGON2I, then the auto-update will be disabled.", "default": 350},
"password_cost_time_interval": { "type": "number", "description": "The interval, in seconds, at which the password cost should be recalculated. Set to -1 to disable. Default: 1 week", "default": 604800},
"password_cost_time_lastcheck": { "type": "number", "description": "Pseudo-setting used to keep track of the last recalculation of password_cost. Is updated with the current unix timestamp every time password_cost is recalculated.", "default": 0},
"new_password_length": { "type": "number", "description": "The length of newly-generated passwords. This is currently used in the user table when creating new accounts.", "default": 32},
"require_login_view": { "type": "checkbox", "description": "Whether to require that users login before they do anything else. Best used with the data_storage_dir option.", "default": false},
"data_storage_dir": { "type": "text", "description": "The directory in which to store all files, except the main index.php.", "default": "." },
"delayed_indexing_time": { "type": "number", "description": "The amount of time, in seconds, that pages should be blocked from being indexed by search engines after their last edit. Aka delayed indexing.", "default": 0},
"nav_links": { "type": "nav", "description": "
An array of links and display text to display at the top of the site.
Format: \"Display Text\": \"Link\"
You can also use strings here and they will be printed as-is, except the following special strings:
user-status
- Expands to the user's login information. e.g. \"Logged in as {name}. | Logout\", or e.g. \"Browsing as Anonymous. | Login\".search
- Expands to a search box.divider
- Expands to a divider to separate stuff.more
- Expands to the \"More...\" submenu.[ [type, path], [type, path], ....]
, where type
is a resource type, and path
is a relative url path to a static file to send via HTTP/2.0 Server Push.$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."); } } // 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, "v0.18-dev" => $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); // 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 integer|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(); // 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; } /** * Figures out whether $settings->css is a url, or a string of css. * A url is something starting with "protocol://" or simply a "/". * @return boolean True if it's a url - false if we assume it's a string of css. */ public static function is_css_url() { global $settings; return preg_match("/^[^\/]*\/\/|^\//", $settings->css); } /** * 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; if(self::is_css_url()) { if($settings->css[0] === "/") // Push it if it's a relative resource self::add_server_push_indicator("style", $settings->css); return "\n"; } else { $css = $settings->css == "auto" ? $defaultCSS : $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"; } } /** * 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 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 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 = "\n"; foreach(static::$jsSnippets as $snippet) $result .= "\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 .= "\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; $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 = ""; 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) { 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"); return false; // Required by some browsers } }); }); '); /// 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 ///// ////////////////////////////////////// // CHANGED: The search redirector has now been moved to below the module registration system, as it was causing a warning here // 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); header("x-login-required: yes"); $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.
")); } ////////////////////////////////////// ////////////////////////////////////// $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" => [ "parser" => function() { throw new Exception("No parser registered!"); }, "hash_generator" => 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, $hash_generator) { 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" => $parser_code, "hash_generator" => $hash_generator ]; } /** * Parses the specified page source using the parser specified in the settings * into HTML. * The specified parser may (though it's unlikely) render it to other things. * @package core * @param string $source The source to render. * @param string $use_cache Whether to use the on-disk cache. Has no effect if parser caching is disabled in peppermint.json, or the source string is too small. * @return string The source rendered to HTML. */ function parse_page_source($source, $use_cache = true) { global $settings, $paths, $parsers, $version; $start_time = microtime(true); if(!$settings->parser_cache || strlen($source) < $settings->parser_cache_min_size) $use_cache = false; 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); */ $cache_id = $parsers[$settings->parser]["hash_generator"]($source); $cache_file = "{$paths->cache_directory}/{$cache_id}.html"; $result = null; if($use_cache && file_exists($cache_file)) { $result = file_get_contents($cache_file); $result .= "\n\n"; } if($result == null) { $result = $parsers[$settings->parser]["parser"]($source); // If we should use the cache and we failed to write to it, warn the admin. // It's not terribible if we can't write to the cache directory (so we shouldn't stop dead & refuse service), but it's still of concern. if($use_cache && !file_put_contents($cache_file, $result)) error_log("[Pepperminty Wiki] Warning: Failed to write to cache file $cache_file."); $result .= "\n\n"; } return $result; } // 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]); } ////////////////////////////////////////////////////////////////// register_module([ "name" => "Password hashing action", "version" => "0.7", "author" => "Starbeamrainbowlabs", "description" => "Adds a utility action (that anyone can use) called hash that hashes a given string. Useful when changing a user's password.", "id" => "action-hash", "code" => function() { /** * @api {get} ?action=hash&string={text} Hash a password * @apiName Hash * @apiGroup Utility * @apiPermission Anonymous * * @apiParam {string} string The string to hash. * @apiParam {boolean} raw Whether to return the hashed password as a raw string instead of as part of an HTML page. * * @apiError ParamNotFound The string parameter was not specified. */ /* * ██ ██ █████ ███████ ██ ██ * ██ ██ ██ ██ ██ ██ ██ * ███████ ███████ ███████ ███████ * ██ ██ ██ ██ ██ ██ ██ * ██ ██ ██ ██ ███████ ██ ██ */ add_action("hash", function() { global $settings; if(!isset($_GET["string"])) { http_response_code(422); exit(page_renderer::render_main("Missing parameter", "The GET
parameter string
must be specified.
It is strongly recommended that you utilise this page via a private or incognito window in order to prevent your password from appearing in your browser history.
")); } else if(!empty($_GET["raw"])) { header("content-type: text/plain"); exit(hash_password($_GET["string"])); } else { exit(page_renderer::render_main("Hashed string", "Algorithm: $settings->password_algorithm
" . $_GET["string"] . "
→ " . hash_password($_GET["string"]) . "
Page protection for $env->page has been $state.
Go back.")); } else { exit(page_renderer::render_main("Error protecting page", "
You are not allowed to protect pages because you are not logged in as a mod or admin. Please try logging out if you are logged in and then try logging in as an administrator.
")); } }); } ]); register_module([ "name" => "Random Page", "version" => "0.3", "author" => "Starbeamrainbowlabs", "description" => "Adds an action called 'random' that redirects you to a random page.", "id" => "action-random", "code" => function() { global $settings; /** * @api {get} ?action=random[&mode={modeName}] Redirects to a random page * @apiName Random * @apiGroup Page * @apiPermission Anonymous * * @apiParam {string} mode The view mode to redirect to. This parameter is basically just passed through to the direct. It works in the same way as the mode parameter on the view action does. */ add_action("random", function() { global $pageindex; $mode = preg_replace("/[^a-z-_]/i", "", $_GET["mode"] ?? ""); $pageNames = array_keys(get_object_vars($pageindex)); // Filter out pages we shouldn't send the user to $pageNames = array_values(array_filter($pageNames, function($pagename) { global $settings, $pageindex; if($settings->random_page_exclude_redirects && isset($pageindex->$pagename->redirect) && $pageindex->$pagename->redirect === true) return false; return preg_match($settings->random_page_exclude, $pagename) === 0 ? true : false; })); $randomPageName = $pageNames[array_rand($pageNames)]; http_response_code(307); $redirect_url = "?page=" . rawurlencode($randomPageName); if(!empty($mode)) $redirect_url .= "&mode=$mode"; header("location: $redirect_url"); }); add_help_section("26-random-redirect", "Jumping to a random page", "$settings->sitename has a function that can send you to a random page. To use it, click here. $settings->admindetails_name ($settings->sitename's adminstrator) may have added it to one of the menus.
"); } ]); register_module([ "name" => "Raw page source", "version" => "0.7", "author" => "Starbeamrainbowlabs", "description" => "Adds a 'raw' action that shows you the raw source of a page.", "id" => "action-raw", "code" => function() { global $settings; /** * @api {get} ?action=raw&page={pageName} Get the raw source code of a page * @apiName RawSource * @apiGroup Page * @apiPermission Anonymous * * @apiParam {string} page The page to return the source of. */ /* * ██████ █████ ██ ██ * ██ ██ ██ ██ ██ ██ * ██████ ███████ ██ █ ██ * ██ ██ ██ ██ ██ ███ ██ * ██ ██ ██ ██ ███ ███ */ add_action("raw", function() { global $pageindex, $env; if(empty($pageindex->{$env->page})) { http_response_code(404); exit("Error: The page with the name $env->page could not be found.\n"); } header("content-type: text/markdown"); header("content-length: " . filesize($env->page_filename)); exit(file_get_contents($env->page_filename)); }); add_help_section("800-raw-page-content", "Viewing Raw Page Content", "Although you can use the edit page to view a page's source, you can also ask $settings->sitename to send you the raw page source and nothing else. This feature is intented for those who want to automate their interaction with $settings->sitename.
To use this feature, navigate to the page for which you want to see the source, and then alter the action
parameter in the url's query string to be raw
. If the action
parameter doesn't exist, add it. Note that when used on an file's page this action will return the source of the description and not the file itself.
$settings->sitename has a status page that returns some basic information about the current state of the wiki in JSON. It can be used as a connection tester - as the Pepperminty Wiki Android app does.
"); } ]); register_module([ "name" => "Sidebar", "version" => "0.3.1", "author" => "Starbeamrainbowlabs", "description" => "Adds a sidebar to the left hand side of every page. Add '\$settings->sidebar_show = true;' to your configuration, or append '&sidebar=yes' to the url to enable. Adding to the url sets a cookie to remember your setting.", "id" => "extra-sidebar", "code" => function() { global $settings; $show_sidebar = false; // Show the sidebar if it is enabled in the settings if(isset($settings->sidebar_show) && $settings->sidebar_show === true) $show_sidebar = true; // Also show and persist the sidebar if the special GET parameter // sidebar is seet if(!$show_sidebar && isset($_GET["sidebar"])) { $show_sidebar = true; // Set a cookie to persist the display of the sidebar setcookie("sidebar_show", "true", time() + (60 * 60 * 24 * 30)); } // Show the sidebar if the cookie is set if(!$show_sidebar && isset($_COOKIE["sidebar_show"])) $show_sidebar = true; // Delete the cookie and hide the sidebar if the special GET paramter // nosidebar is set if(isset($_GET["nosidebar"])) { $show_sidebar = false; unset($_COOKIE["sidebar_show"]); setcookie("sidebar_show", null, time() - 3600); } page_renderer::register_part_preprocessor(function(&$parts) use ($show_sidebar) { global $settings, $pageindex, $env; // Don't render a sidebar if the user is logging in and a login is // required in order to view pages. if($settings->require_login_view && in_array($env->action, [ "login", "checklogin" ])) return false; if($show_sidebar && !isset($_GET["printable"])) { // Show the sidebar $exec_start = microtime(true); // Sort the pageindex $sorted_pageindex = get_object_vars($pageindex); ksort($sorted_pageindex, SORT_NATURAL); $sidebar_contents = ""; $sidebar_contents .= render_sidebar($sorted_pageindex); $parts["{body}"] = "$settings->sitename has an optional sidebar which displays a list of all the current pages (but not subpages) that it is currently hosting. It may or may not be enabled.
If it isn't enabled, it can be enabled for your current browser only by appending sidebar=yes
to the current page's query string.
Your comment couldn't be posted because you're not logged in. You can login here. Here's the comment you tried to post:
")); } $message_length = strlen($message); if($message_length < $settings->comment_min_length) { http_response_code(422); exit(page_renderer::render_main("Error posting comment - $settings->sitename", "Your comment couldn't be posted because it was too short. $settings->sitename needs at $settings->comment_min_length characters in a comment in order to post it.
")); } if($message_length > $settings->comment_max_length) { http_response_code(422); exit(page_renderer::renderer_main("Error posting comment - $settings->sitename", "Your comment couldn't be posted because it was too long. $settings->sitenamae can only post comments that are up to $settings->comment_max_length characters in length, and yours was $message_length characters. Try splitting it up into multiple comments! Here's the comment you tried to post:
")); } // Figure out where the comments are stored $comment_filename = get_comment_filename($env->page); if(!file_exists($comment_filename)) { if(file_put_contents($comment_filename, "[]\n") === false) { http_response_code(503); exit(page_renderer::renderer_main("Error posting comment - $settings->sitename", "$settings->sitename ran into a problem whilst creating a file to save your comment to! Please contact $settings->admindetails_name, $settings->sitename's administrator and tell them about this problem.
")); } } $comment_data = json_decode(file_get_contents($comment_filename)); $new_comment = new stdClass(); $new_comment->id = generate_comment_id(); $new_comment->timestamp = date("c"); $new_comment->username = $env->user; $new_comment->logged_in = $env->is_logged_in; $new_comment->message = $message; $new_comment->replies = []; if($reply_to == null) $comment_data[] = $new_comment; else { $parent_comment = find_comment($comment_data, $reply_to); if($parent_comment === false) { http_response_code(422); exit(page_renderer::render_main("Error posting comment - $settings->sitename", "$settings->sitename couldn't post your comment because it couldn't find the parent comment you replied to. It's possible that $settings->admindetails_name, $settings->sitename's administrator, deleted the comment. Here's the comment you tried to post:
")); } $parent_comment->replies[] = $new_comment; // Get an array of all the parent comments we need to notify $comment_thread = fetch_comment_thread($comment_data, $parent_comment->id); $email_subject = "[Notification] $env->user replied to your comment on $env->page - $settings->sitename"; foreach($comment_thread as $thread_comment) { $email_body = "Hello, {username}!\n" . "It's $settings->sitename here, letting you know that " . "someone replied to your comment (or a reply to your comment) on $env->page.\n" . "\n" . "They said:\n" . "\n" . "$new_comment->message" . "\n" . "You said on " . date("c", strtotime($thread_comment->timestamp)) . ":\n" . "\n" . "$thread_comment->message\n" . "\n"; email_user($thread_comment->username, $email_subject, $email_body); } } // Save the comments back to disk if(file_put_contents($comment_filename, json_encode($comment_data, JSON_PRETTY_PRINT)) === false) { http_response_code(503); exit(page_renderer::renderer_main("Error posting comment - $settings->sitename", "$settings->sitename ran into a problem whilst saving your comment to disk! Please contact $settings->admindetails_name, $settings->sitename's administrator and tell them about this problem.
")); } // Add a recent change if the recent changes module is installed if(module_exists("feature-recent-changes")) { add_recent_change([ "type" => "comment", "timestamp" => time(), "page" => $env->page, "user" => $env->user, "reply_depth" => count($comment_thread), "comment_id" => $new_comment->id ]); } http_response_code(307); header("location: ?action=view&page=" . rawurlencode($env->page) . "&commentsuccess=yes#comment-$new_comment->id"); exit(page_renderer::render_main("Comment posted successfully - $settings->sitename", "Your comment on $env->page was posted successfully. If your browser doesn't redirect you automagically, please click here to go to the comment you posted on the page you were viewing.
")); }); /** * @api {post} ?action=comment-delete&page={page_name}&delete_id={id_to_delete} Delete a comment * @apiName CommentDelete * @apiGroup Comment * @apiPermission User * @apiDescription Deletes a comment with the specified id. If you aren't the one who made the comment in the first place, then you must be a moderator or better to delete it. * * @apiUse PageParameter * @apiParam {string} delete_id The id of the comment to delete. * * @apiError CommentNotFound The comment to delete was not found. */ /* * ██████ ██████ ███ ███ ███ ███ ███████ ███ ██ ████████ * ██ ██ ██ ████ ████ ████ ████ ██ ████ ██ ██ * ██ ██ ██ ██ ████ ██ ██ ████ ██ █████ ██ ██ ██ ██ █████ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ * ██████ ██████ ██ ██ ██ ██ ███████ ██ ████ ██ * ██████ ███████ ██ ███████ ████████ ███████ * ██ ██ ██ ██ ██ ██ ██ * ██ ██ █████ ██ █████ ██ █████ * ██ ██ ██ ██ ██ ██ ██ * ██████ ███████ ███████ ███████ ██ ███████ */ add_action("comment-delete", function () { global $env, $settings; if(!isset($_GET["delete_id"])) { http_response_code(400); exit(page_renderer::render_main("Error - Deleting Comment - $settings->sitename", "You didn't specify the id of a comment to delete.
")); } // Make sure that the user is logged in before deleting a comment if(!$env->is_logged_in) { http_response_code(307); header("location: ?action=login&returnto=" . rawurlencode("?action=comment-delete&page=" . rawurlencode($env->page) . "&id=" . rawurlencode($_GET["delete_id"]))); } $comment_filename = get_comment_filename($env->page); $comments = json_decode(file_get_contents($comment_filename)); $target_id = $_GET["delete_id"]; $comment_to_delete = find_comment($comments, $target_id); if($comment_to_delete->username !== $env->user && !$env->is_admin) { http_response_code(401); exit(page_renderer::render_main("Error - Deleting Comment - $settings->sitename", "You can't delete the comment with the id " . htmlentities($target_id) . "
on the page $env->page because you're logged in as " . page_renderer::render_username($env->user) . ", and " . page_renderer::render_username($comment_to_delete->username) . " made that comment. Try Logging out and then logging in again as " . page_renderer::render_username($comment_to_delete->username) . ", or as a moderator or better."));
}
if(!delete_comment($comments, $_GET["delete_id"])) {
http_response_code(404);
exit(page_renderer::render_main("Comment not found - Deleting Comment - $settings->sitename", "
The comment with the id " . htmlentities($_GET["delete_id"]) . "
on the page $env->page wasn't found. Perhaps it was already deleted?
While $settings->sitename was able to delete the comment with the id " . htmlentities($target_id) . "
on the page $env->page, it couldn't save the changes back to disk. Please contact $settings->admindetails_name, $settings->sitename's local friendly administrator about this issue.
The comment with the id " . htmlentities($target_id) . "
on the page $env->page has been deleted successfully. Go back to " . htmlentities($env->page) . ".
$settings->sitename has a threaded commenting system on every page. You can find it below each page's content, and can either leave a new comment, or reply to an existing one. If you reply to an existing one, then the authors of all the comments above yours will get notified by email of your reply - so long as they have an email address registered in their preferences.
"); } ]); /** * Given a page name, returns the absolute file path in which that page's * comments are stored. * @package feature-comments * @param string $pagename The name pf the page to fetch the comments filename for. * @return string The path to the file that the */ function get_comment_filename($pagename) { global $env; $pagename = makepathsafe($pagename); return "$env->storage_prefix$pagename.comments.json"; } /** * Generates a new random comment id. * @package feature-comments * @return string A new random comment id. */ function generate_comment_id() { $result = base64_encode(random_bytes(16)); $result = str_replace(["+", "/", "="], ["-", "_"], $result); return $result; } /** * Finds the comment with specified id by way of an almost-breadth-first search. * @package feature-comments * @param array $comment_data The comment data to search. * @param string $comment_id The id of the comment to find. * @return object The comment data with the specified id, or * false if it wasn't found. */ function find_comment($comment_data, $comment_id) { $subtrees = []; foreach($comment_data as $comment) { if($comment->id === $comment_id) return $comment; if(count($comment->replies) > 0) { $subtrees[] = $comment->replies; } } foreach($subtrees as $subtree) { $subtree_result = find_comment($subtree, $comment_id); if($subtree_result !== false) return $subtree_result; } return false; } /** * Deletes the first comment found with the specified id. * @param array $comment_data An array of threaded comments to delete the comment from. * @param string $target_id The id of the comment to delete. * @return bool Whether the comment was found and deleted or not. */ function delete_comment(&$comment_data, $target_id) { $comment_count = count($comment_data); if($comment_count === 0) return false; for($i = 0; $i < $comment_count; $i++) { if($comment_data[$i]->id == $target_id) { if(count($comment_data[$i]->replies) == 0) { unset($comment_data[$i]); // Reindex the comment list before returning $comment_data = array_values($comment_data); } else { unset($comment_data[$i]->username); $comment_data[$i]->message = "_[Deleted]_"; } return true; } if(count($comment_data[$i]->replies) > 0 && delete_comment($comment_data[$i]->replies, $target_id)) return true; } return false; } /** * Fetches all the parent comments of the specified comment id, including the * comment itself at the end. * Useful for figuring out who needs notifying when a new comment is posted. * @package feature-comments * @param array $comment_data The comment data to search. * @param string $comment_id The comment id to fetch the thread for. * @return object[] A list of the comments in the thread, with the deepest * one at the end. */ function fetch_comment_thread($comment_data, $comment_id) { foreach($comment_data as $comment) { // If we're the comment they're looking for, then return ourselves as // the beginning of a thread if($comment->id === $comment_id) return [ $comment ]; if(count($comment->replies) > 0) { $subtree_result = fetch_comment_thread($comment->replies, $comment_id); if($subtree_result !== false) { // Prepend ourselves to the result array_unshift($subtree_result, $comment); return $subtree_result; // Return the comment thread } } } return false; } /** * Renders a given comments tree to html. * @package feature-comments * @param object[] $comments_data The comments tree to render. * @param integer $depth For internal use only. Specifies the depth * at which the comments are being rendered. * @return string The given comments tree as html. */ function render_comments($comments_data, $depth = 0) { global $settings, $env; if(count($comments_data) == 0) { if($depth == 0) return "No comments here! Start the conversation above.
"; else return ""; } $result = "You don't have permission to change $settings->sitename's master settings.
\n"; if(!$env->is_logged_in) $errorMessage .= "You could try logging in.
"; else $errorMessage .= "You could try logging out and then logging in again with a different account that has the appropriate privileges..
"; exit(page_renderer::render_main("Error - $settings->sitename", $errorMessage)); } $content = "This page lets you configure $settings->sitename's master settings. Please be careful - you can break things easily on this page if you're not careful!
\n"; $content .= "You're currently running Pepperminty Wiki $version+" . substr($commit, 0, 7) . ".
\n"; $content .= "Your upload couldn't be processed because editing is currently disabled on $settings->sitename. Please contact $settings->admindetails_name, $settings->sitename's administrator for more information - their contact details can be found at the bottom of this page. Go back to the main page.")); } // Make sure uploads are enabled if(!$settings->upload_enabled) { if(!empty($_FILES["file"])) unlink($_FILES["file"]["tmp_name"]); http_response_code(412); exit(page_renderer::render("Upload failed - $settings->sitename", "
Your upload couldn't be processed because uploads are currently disabled on $settings->sitename. Go back to the main page.
")); } // Make sure that the user is logged in if(!$env->is_logged_in) { if(!empty($_FILES["file"])) unlink($_FILES["file"]["tmp_name"]); http_response_code(401); exit(page_renderer::render("Upload failed - $settings->sitename", "Your upload couldn't be processed because you are not logged in.
Try logging in first.")); } // Check for php upload errors if($_FILES["file"]["error"] > 0) { if(!empty($_FILES["file"])) unlink($_FILES["file"]["tmp_name"]); if($_FILES["file"]["error"] == 1 || $_FILES["file"]["error"] == 2) http_response_code(413); // file is too large else http_response_code(500); // something else went wrong exit(page_renderer::render("Upload failed - $settings->sitename", "
Your upload couldn't be processed because " . (($_FILES["file"]["error"] == 1 || $_FILES["file"]["error"] == 2) ? "the file is too large" : "an error occurred") . ".
Please contact $settings->admindetails_name, $settings->sitename's administrator for help.
")); } // Calculate the target name, removing any characters we // are unsure about. $target_name = makepathsafe($_POST["name"] ?? "Users/$env->user/Avatar"); $temp_filename = $_FILES["file"]["tmp_name"]; $mimechecker = finfo_open(FILEINFO_MIME_TYPE); $mime_type = finfo_file($mimechecker, $temp_filename); finfo_close($mimechecker); if(!in_array($mime_type, $settings->upload_allowed_file_types)) { http_response_code(415); exit(page_renderer::render("Unknown file type - Upload error - $settings->sitename", "$settings->sitename recieved the file you tried to upload successfully, but detected that the type of file you uploaded is not in the allowed file types list. The file has been discarded.
The file you tried to upload appeared to be of type $mime_type
, but $settings->sitename currently only allows the uploading of the following file types: " . implode("
, ", $settings->upload_allowed_file_types) . "
.
Go back to the Main Page.
")); } // Perform appropriate checks based on the *real* filetype if($is_avatar && substr($mime_type, 0, strpos($mime_type, "/")) !== "image") { http_response_code(415); exit(page_renderer::render_main("Error uploading avatar - $settings->sitename", "That file appears to be unsuitable as an avatar, as $settings->sitename has detected it to be of type $mime_type
, which doesn't appear to be an image. Please try uploading a different file to use as your avatar.
Although the file that you uploaded appears to be an image, $settings->sitename has been unable to determine it's dimensions. The uploaded file has been discarded. Go back to try again.
You may wish to consider opening an issue against Pepperminty Wiki (the software that powers $settings->sitename) if this isn't the first time that you have seen this message.
")); } break; } $file_extension = system_mime_type_extension($mime_type); // Override the detected file extension if a file extension // is explicitly specified in the settings if(isset($settings->mime_mappings_overrides->$mime_type)) $file_extension = $settings->mime_mappings_overrides->$mime_type; if(in_array($file_extension, [ "php", ".htaccess", "asp", "aspx" ])) { http_response_code(415); exit(page_renderer::render("Upload Error - $settings->sitename", "The file you uploaded appears to be dangerous and has been discarded. Please contact $settings->sitename's administrator for assistance.
Additional information: The file uploaded appeared to be of type $mime_type
, which mapped onto the extension $file_extension
. This file extension has the potential to be executed accidentally by the web server.
A page or file has already been uploaded with the name '$new_filename'. Try deleting it first. If you do not have permission to delete things, try contacting one of the moderators.
")); // Delete the previously uploaded avatar, if it exists // In the future we _may_ not need this once we have // file history online. if($is_avatar && isset($pageindex->$new_pagepath) && $pageindex->$new_pagepath->uploadedfile) unlink($pageindex->$new_pagepath->uploadedfilepath); // Make sure that the palce we're uploading to exists if(!file_exists(dirname($env->storage_prefix . $new_filename))) mkdir(dirname($env->storage_prefix . $new_filename), 0775, true); if(!move_uploaded_file($temp_filename, $env->storage_prefix . $new_filename)) { http_response_code(409); exit(page_renderer::render("Upload Error - $settings->sitename", "The file you uploaded was valid, but $settings->sitename couldn't verify that it was tampered with during the upload process. This probably means that either is a configuration error, or that $settings->sitename has been attacked. Please contact " . $settings->admindetails_name . ", your $settings->sitename Administrator.
")); } $description = $_POST["description"] ?? "_(No description provided)_\n"; // Escape the raw html in the provided description if the setting is enabled if($settings->clean_raw_html) $description = htmlentities($description, ENT_QUOTES); file_put_contents($env->storage_prefix . $new_description_filename, $description); // Construct a new entry for the pageindex $entry = new stdClass(); // Point to the description's filepath since this property // should point to a markdown file $entry->filename = $new_description_filename; $entry->size = strlen($description ?? "(No description provided)"); $entry->lastmodified = time(); $entry->lasteditor = $env->user; $entry->uploadedfile = true; $entry->uploadedfilepath = $new_filename; $entry->uploadedfilemime = $mime_type; // Add the new entry to the pageindex // Assign the new entry to the image's filepath as that // should be the page name. $pageindex->$new_pagepath = $entry; // Generate a revision to keep the page history up to date if(module_exists("feature-history")) { $oldsource = ""; // Only variables can be passed by reference, not literals history_add_revision($entry, $description, $oldsource, false); } // Save the pageindex file_put_contents($paths->pageindex, json_encode($pageindex, JSON_PRETTY_PRINT)); if(module_exists("feature-recent-changes")) { add_recent_change([ "type" => "upload", "timestamp" => time(), "page" => $new_pagepath, "user" => $env->user, "filesize" => filesize($env->storage_prefix . $entry->uploadedfilepath) ]); } header("location: ?action=view&page=$new_pagepath&upload=success"); break; } }); /** * @api {get} ?action=preview&page={pageName}[&size={someSize}] Get a preview of a file * @apiName PreviewFile * @apiGroup Upload * @apiPermission Anonymous * * @apiParam {string} page The name of the file to preview. * @apiParam {number} size Optional. The size fo the resulting preview. Will be clamped to fit within the bounds specified in the wiki's settings. May also be set to the keyword 'original', which will cause the original file to be returned with it's appropriate mime type instead. * * @apiError PreviewNoFileError No file was found associated with the specified page. * @apiError PreviewUnknownFileTypeError Pepperminty Wiki was unable to generate a preview for the requested file's type. */ /* * ██████ ██████ ███████ ██ ██ ██ ███████ ██ ██ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ * ██████ ██████ █████ ██ ██ ██ █████ ██ █ ██ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ * ██ ██ ██ ███████ ████ ██ ███████ ███ ███ */ add_action("preview", function() { global $settings, $env, $pageindex, $start_time; if(empty($pageindex->{$env->page}->uploadedfilepath)) { $im = errorimage("The page '$env->page' doesn't have an associated file."); header("content-type: image/png"); imagepng($im); exit(); } $filepath = realpath($env->storage_prefix . $pageindex->{$env->page}->uploadedfilepath); $mime_type = $pageindex->{$env->page}->uploadedfilemime; $shortFilename = substr($filepath, 1 + (strrpos($filepath, '/') !== false ? strrpos($filepath, '/') : -1)); header("content-disposition: inline; filename=\"$shortFilename\""); header("last-modified: " . gmdate('D, d M Y H:i:s T', $pageindex->{$env->page}->lastmodified)); // If the size is set to original, then send (or redirect to) the original image // Also do the same for SVGs if svg rendering is disabled. if(isset($_GET["size"]) and $_GET["size"] == "original" or (empty($settings->render_svg_previews) && $mime_type == "image/svg+xml")) { // Get the file size $filesize = filesize($filepath); // Send some headers header("content-length: $filesize"); header("content-type: $mime_type"); // Open the file and send it to the user $handle = fopen($filepath, "rb"); fpassthru($handle); fclose($handle); exit(); } // Determine the target size of the image $target_size = 512; if(isset($_GET["size"])) $target_size = intval($_GET["size"]); if($target_size < $settings->min_preview_size) $target_size = $settings->min_preview_size; if($target_size > $settings->max_preview_size) $target_size = $settings->max_preview_size; // Determine the output file type $output_mime = $settings->preview_file_type; if(isset($_GET["type"]) and in_array($_GET["type"], [ "image/png", "image/jpeg", "image/webp" ])) $output_mime = $_GET["type"]; /// ETag handling /// // Generate the etag and send it to the client $preview_etag = sha1("$output_mime|$target_size|$filepath|$mime_type"); $allheaders = getallheaders(); $allheaders = array_change_key_case($allheaders, CASE_LOWER); if(!isset($allheaders["if-none-match"])) { header("etag: $preview_etag"); } else { if($allheaders["if-none-match"] === $preview_etag) { http_response_code(304); header("x-generation-time: " . (microtime(true) - $start_time)); exit(); } } /// ETag handling end /// /* Disabled until we work out what to do about caching previews * $previewFilename = "$filepath.preview.$outputFormat"; if($target_size === $settings->default_preview_size) { // The request is for the default preview size // Check to see if we have a preview pre-rendered } */ $preview = new Imagick(); switch(substr($mime_type, 0, strpos($mime_type, "/"))) { case "image": $preview->readImage($filepath); break; case "application": if($mime_type == "application/pdf") { $preview = new imagick(); $preview->readImage("{$filepath}[0]"); $preview->setResolution(300,300); $preview->setImageColorspace(255); break; } case "video": case "audio": if($settings->data_storage_dir == ".") { // The data storage directory is the current directory // Redirect to the file isntead http_response_code(307); header("location: " . $pageindex->{$env->page}->uploadedfilepath); exit(); } // TODO: Add support for ranges here. // Get the file size $filesize = filesize($filepath); // Send some headers header("content-length: $filesize"); header("content-type: $mime_type"); // Open the file and send it to the user $handle = fopen($filepath, "rb"); fpassthru($handle); fclose($handle); exit(); break; default: http_response_code(501); $preview = errorimage("Unrecognised file type '$mime_type'.", $target_size); header("content-type: image/png"); imagepng($preview); exit(); } // Scale the image down to the target size $preview->resizeImage($target_size, $target_size, imagick::FILTER_LANCZOS, 1, true); // Send the completed preview image to the user header("content-type: $output_mime"); header("x-generation-time: " . (microtime(true) - $start_time) . "s"); $outputFormat = substr($output_mime, strpos($output_mime, "/") + 1); $preview->setImageFormat($outputFormat); echo($preview->getImageBlob()); /* Disabled while we work out what to do about caching previews * // Save a preview file if there isn't one alreaddy if(!file_exists($previewFilename)) file_put_contents($previewFilename, $preview->getImageBlob()); */ }); /* * ██████ ██████ ███████ ██ ██ ██ ███████ ██ ██ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ * ██████ ██████ █████ ██ ██ ██ █████ ██ █ ██ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ * ██ ██ ██ ███████ ████ ██ ███████ ███ ███ * * ██████ ██ ███████ ██████ ██ █████ ██ ██ ███████ ██████ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ * ██ ██ ██ ███████ ██████ ██ ███████ ████ █████ ██████ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ * ██████ ██ ███████ ██ ███████ ██ ██ ██ ███████ ██ ██ */ page_renderer::register_part_preprocessor(function(&$parts) { global $pageindex, $env, $settings; // Don't do anything if the action isn't view if($env->action !== "view") return; if(isset($pageindex->{$env->page}->uploadedfile) and $pageindex->{$env->page}->uploadedfile == true) { // We are looking at a page that is paired with an uploaded file $filepath = $pageindex->{$env->page}->uploadedfilepath; $mime_type = $pageindex->{$env->page}->uploadedfilemime; $dimensions = $mime_type !== "image/svg+xml" ? getimagesize($env->storage_prefix . $filepath) : getsvgsize($env->storage_prefix . $filepath); $fileTypeDisplay = substr($mime_type, 0, strpos($mime_type, "/")); $previewUrl = "?action=preview&size=$settings->default_preview_size&page=" . rawurlencode($env->page); $preview_html = ""; switch($fileTypeDisplay) { case "application": case "image": if($mime_type == "application/pdf") $fileTypeDisplay = "file"; $originalUrl = $env->storage_prefix == "./" ? $filepath : "?action=preview&size=original&page=" . rawurlencode($env->page); $preview_sizes = [ 256, 512, 768, 1024, 1440, 1920 ]; $preview_html .= "\t\t\t"; break; case "video": $preview_html .= "\t\t\t"; break; case "audio": $preview_html .= "\t\t\t"; } $fileInfo = []; $fileInfo["Name"] = str_replace("Files/", "", $filepath); $fileInfo["Type"] = $mime_type; $fileInfo["Size"] = human_filesize(filesize($env->storage_prefix . $filepath)); switch($fileTypeDisplay) { case "image": $dimensionsKey = $mime_type !== "image/svg+xml" ? "Original dimensions" : "Native size"; $fileInfo[$dimensionsKey] = "$dimensions[0] x $dimensions[1]"; break; } $fileInfo["Uploaded by"] = $pageindex->{$env->page}->lasteditor; $fileInfo["Short markdown embed code"] = " "; $preview_html .= "\t\t\t$displayName | $displayValue |
---|
$settings->sitename supports the uploading of files, though it is up to " . $settings->admindetails_name . ", $settings->sitename's administrator as to whether it is enabled or not (uploads are currently " . (($settings->upload_enabled) ? "enabled" : "disabled") . ").
Currently Pepperminty Wiki (the software that $settings->sitename uses) only supports the uploading of images, although more file types should be supported in the future (open an issue on GitHub if you are interested in support for more file types).
Uploading a file is actually quite simple. Click the "Upload" option in the "More..." menu to go to the upload page. The upload page will tell you what types of file $settings->sitename allows, and the maximum supported filesize for files that you upload (this is usually set by the web server that the wiki is running on).
Use the file chooser to select the file that you want to upload, and then decide on a name for it. Note that the name that you choose should not include the file extension, as this will be determined automatically. Enter a description that will appear on the file's page, and then click upload.
"); } ]); /** * Calculates the actual maximum upload size supported by the server * Returns a file size limit in bytes based on the PHP upload_max_filesize and * post_max_size * @package feature-upload * @author Lifted from Drupal by @meustrus from Stackoverflow * @see http://stackoverflow.com/a/25370978/1460422 Source Stackoverflow answer * @return integer The maximum upload size supported bythe server, in bytes. */ function get_max_upload_size() { static $max_size = -1; if ($max_size < 0) { // Start with post_max_size. $max_size = parse_size(ini_get('post_max_size')); // If upload_max_size is less, then reduce. Except if upload_max_size is // zero, which indicates no limit. $upload_max = parse_size(ini_get('upload_max_filesize')); if ($upload_max > 0 && $upload_max < $max_size) { $max_size = $upload_max; } } return $max_size; } /** * Parses a PHP size to an integer * @package feature-upload * @author Lifted from Drupal by @meustrus from Stackoverflow * @see http://stackoverflow.com/a/25370978/1460422 Source Stackoverflow answer * @param string $size The size to parse. * @return integer The number of bytees represented by the specified * size string. */ function parse_size($size) { $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); // Remove the non-unit characters from the size. $size = preg_replace('/[^0-9\.]/', '', $size); // Remove the non-numeric characters from the size. if ($unit) { // Find the position of the unit in the ordered string which is the power of magnitude to multiply a kilobyte by. return round($size * pow(1024, stripos('bkmgtpezy', $unit[0]))); } else { return round($size); } } /** * Checks an uploaded SVG file to make sure it's (at least somewhat) safe. * Sends an error to the client if a problem is found. * @package feature-upload * @param string $temp_filename The filename of the SVG file to check. * @return int[] The size of the SVG image. */ function upload_check_svg($temp_filename) { global $settings; // Check for script tags if(strpos(file_get_contents($temp_filename), " \n"; header("x-failure-reason: edit-conflict"); exit(page_renderer::render_main("Edit Conflict - $env->page - $settings->sitename", $content)); } } // -----~~~==~~~----- // Update the inverted search index // Construct an index for the old and new page content $oldindex = []; $oldpagedata = ""; // We need the old page data in order to pass it to the preprocessor if(file_exists("$env->storage_prefix$env->page.md")) { $oldpagedata = file_get_contents("$env->storage_prefix$env->page.md"); $oldindex = search::index($oldpagedata); } $newindex = search::index($pagedata); // Compare the indexes of the old and new content $additions = []; $removals = []; search::compare_indexes($oldindex, $newindex, $additions, $removals); // Load in the inverted index $invindex = search::load_invindex($env->storage_prefix . "invindex.json"); // Merge the changes into the inverted index search::merge_into_invindex($invindex, ids::getid($env->page), $additions, $removals); // Save the inverted index back to disk search::save_invindex($env->storage_prefix . "invindex.json", $invindex); // -----~~~==~~~----- if(file_put_contents("$env->storage_prefix$env->page.md", $pagedata) !== false) { // Make sure that this page's parents exist check_subpage_parents($env->page); // Update the page index if(!isset($pageindex->{$env->page})) { $pageindex->{$env->page} = new stdClass(); $pageindex->{$env->page}->filename = "$env->page.md"; } $pageindex->{$env->page}->size = strlen($_POST["content"]); $pageindex->{$env->page}->lastmodified = time(); if($env->is_logged_in) $pageindex->{$env->page}->lasteditor = $env->user; else // TODO: Add an option to record the user's IP here instead $pageindex->{$env->page}->lasteditor = "anonymous"; $pageindex->{$env->page}->tags = $page_tags; // A hack to resave the pagedata if the preprocessors have // changed it. We need this because the preprocessors *must* // run _after_ the pageindex has been updated. $pagedata_orig = $pagedata; // Execute all the preprocessors foreach($save_preprocessors as $func) { $func($pageindex->{$env->page}, $pagedata, $oldpagedata); } if($pagedata !== $pagedata_orig) file_put_contents("$env->storage_prefix$env->page.md", $pagedata); file_put_contents($paths->pageindex, json_encode($pageindex, JSON_PRETTY_PRINT)); if(isset($_GET["newpage"])) http_response_code(201); else http_response_code(200); // header("content-type: text/plain"); header("location: index.php?page=" . rawurlencode($env->page) . "&edit_status=success&redirect=no"); exit(); } else { header("x-failure-reason: server-error"); http_response_code(507); exit(page_renderer::render_main("Error saving page - $settings->sitename", "$settings->sitename failed to write your changes to the server's disk. Your changes have not been saved, but you might be able to recover your edit by pressing the back button in your browser.
Please tell the administrator of this wiki (" . $settings->admindetails_name . ") about this problem.
")); } }); add_help_section("15-editing", "Editing", "To edit a page on $settings->sitename, click the edit button on the top bar. Note that you will probably need to be logged in. If you do not already have an account you will need to ask $settings->sitename's administrator for an account since there is no registration form. Note that the $settings->sitename's administrator may have changed these settings to allow anonymous edits.
Editing is simple. The edit page has a sizeable box that contains a page's current contents. Once you are done altering it, add or change the comma separated list of tags in the field below the editor and then click save page.
A reference to the syntax that $settings->sitename supports can be found below.
"); add_help_section("17-user-pages", "User Pages", "If you are logged in, $settings->sitename allocates you your own user page that only you can edit. On $settings->sitename, user pages are sub-pages of the " . htmlentities($settings->user_page_prefix) . " page, and each user page can have a nested structure of pages underneath it, just like a normal page. Your user page is located at " . htmlentities(get_user_pagename($env->user)) . ". " . (module_exists("page-user-list") ? "You can see a list of all the users on $settings->sitename and visit their user pages on the user list." : "") . "
"); } ]); /** * Generates a unique hash of a page's content for edit conflict detection * purposes. * @param string $page_data The page text to hash. * @return string A hash of the given page text. */ function generate_page_hash($page_data) { return sha1($page_data); } register_module([ "name" => "Export", "version" => "0.5", "author" => "Starbeamrainbowlabs", "description" => "Adds a page that you can use to export your wiki as a .zip file. Uses \$settings->export_only_allow_admins, which controls whether only admins are allowed to export the wiki.", "id" => "page-export", "code" => function() { global $settings; /** * @api {get} ?action=export Export the all the wiki's content * @apiDescription Export all the wiki's content. Please ask for permission before making a request to this URI. Note that some wikis may only allow moderators to export content. * @apiName Export * @apiGroup Utility * @apiPermission Anonymous * * @apiError InsufficientExportPermissionsError The wiki has the export_allow_only_admins option turned on, and you aren't logged into a moderator account. * @apiError CouldntOpenTempFileError Pepperminty Wiki couldn't open a temporary file to send the compressed archive to. * @apiError CouldntCloseTempFileError Pepperminty Wiki couldn't close the temporary file to finish creating the zip archive ready for downloading. */ /* * ███████ ██ ██ ██████ ██████ ██████ ████████ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ * █████ ███ ██████ ██ ██ ██████ ██ * ██ ██ ██ ██ ██ ██ ██ ██ ██ * ███████ ██ ██ ██ ██████ ██ ██ ██ */ add_action("export", function() { global $settings, $pageindex, $env; if($settings->export_allow_only_admins && !$env->is_admin) { http_response_code(401); exit(page_renderer::render("Export error - $settings->sitename", "Only administrators of $settings->sitename are allowed to export the wiki as a zip. Return to the $settings->defaultpage.")); } $tmpfilename = tempnam(sys_get_temp_dir(), "pepperminty-wiki-"); $zip = new ZipArchive(); if($zip->open($tmpfilename, ZipArchive::CREATE) !== true) { http_response_code(507); exit(page_renderer::render("Export error - $settings->sitename", "Pepperminty Wiki was unable to open a temporary file to store the exported data in. Please contact $settings->sitename's administrator (" . $settings->admindetails_name . " at " . hide_email($settings->admindetails_email) . ") for assistance.")); } foreach($pageindex as $entry) { $zip->addFile("$env->storage_prefix$entry->filename", $entry->filename); if(isset($entry->uploadedfilepath)) $zip->addFile($entry->uploadedfilepath); } if($zip->close() !== true) { http_response_code(500); exit(page_renderer::render("Export error - $settings->sitename", "Pepperminty wiki was unable to close the temporary zip file after creating it. Please contact $settings->sitename's administrator (" . $settings->admindetails_name . " at " . hide_email($settings->admindetails_email) . ") for assistance.")); } header("content-type: application/zip"); header("content-disposition: attachment; filename=$settings->sitename-export.zip"); header("content-length: " . filesize($tmpfilename)); $zip_handle = fopen($tmpfilename, "rb"); fpassthru($zip_handle); fclose($zip_handle); unlink($tmpfilename); }); // Add a section to the help page add_help_section("50-export", "Exporting", "$settings->sitename supports exporting the entire wiki's content as a zip. Note that you may need to be a moderator in order to do this. Also note that you should check for permission before doing so, even if you are able to export without asking.
To perform an export, go to the credits page and click "Export as zip - Check for permission first".
"); } ]); register_module([ "name" => "Help page", "version" => "0.9.3", "author" => "Starbeamrainbowlabs", "description" => "Adds a rather useful help page. Access through the 'help' action. This module also exposes help content added to Pepperminty Wiki's inbuilt invisible help section system.", "id" => "page-help", "code" => function() { global $settings; /** * @api {get} ?action=help[&dev=yes] Get a help page * @apiDescription Get a customised help page. This page will be slightly different for every wiki, depending on their name, settings, and installed modules. * @apiName Help * @apiGroup Utility * @apiPermission Anonymous * * @apiParam {string} dev Set to 'yes' to get a developer help page instead. The developer help page gives some general information about which modules and help page sections are registered, and other various (non-sensitive) settings. */ /* * ██ ██ ███████ ██ ██████ * ██ ██ ██ ██ ██ ██ * ███████ █████ ██ ██████ * ██ ██ ██ ██ ██ * ██ ██ ███████ ███████ ██ */ add_action("help", function() { global $env, $paths, $settings, $version, $help_sections, $actions; // Sort the help sections by key ksort($help_sections, SORT_NATURAL); if(isset($_GET["dev"]) and $_GET["dev"] == "yes") { $title = "Developers Help - $settings->sitename"; $content = "$settings->sitename runs on Pepperminty Wiki, an entire wiki packed into a single file. This page contains some information that developers may find useful.
A full guide to developing a Pepperminty Wiki module can be found on GitHub.
The following help sections are currently registered:
Index | Title | Length |
---|---|---|
$index | " . $section["title"] . " | " . human_filesize($sectionLength) . " |
Total: | " . human_filesize($totalSize) . " |
The following actions are currently registered:
\n"; $content .= "" . implode(", ", $registeredActions) . "
"; $content .= "$settings->sitename is currently " . human_filesize($wikiSize->all) . " in size.
\n"; $content .= " "; } else { $title = "Help - $settings->sitename"; $content = "Welcome to $settings->sitename!
$settings->sitename is powered by Pepperminty Wiki, a complete wiki in a box you can drop into your server and expect it to just work.
"; // Todo Insert a table of contents here? foreach($help_sections as $index => $section) { // Todo add a button that you can click to get a permanent link // to this section. $content .= "All the navigation links can be found on the top bar, along with a search box (if your site administrator has enabled it). There is also a "More..." menu in the top right that contains some additional links that you may fine useful.
This page, along with the credits page, can be found on the bar at the bottom of every page.
"); add_help_section("1-extra", "Extra Information", "You can find out whch version of Pepperminty Wiki $settings->sitename is using by visiting the credits page.
Information for developers can be found on this page.
"); } ]); register_module([ "name" => "Page list", "version" => "0.11", "author" => "Starbeamrainbowlabs", "description" => "Adds a page that lists all the pages in the index along with their metadata.", "id" => "page-list", "code" => function() { global $settings; /** * @api {get} ?action=list[&format={format}] List all pages * @apiDescription Gets a list of all the pages currently stored on the wiki. * @apiName ListPages * @apiGroup Page * @apiPermission Anonymous * * @apiParam {string} format The format to return the page list in. Default: html. Other foramts available: json, text */ /* * ██ ██ ███████ ████████ * ██ ██ ██ ██ * ██ ██ ███████ ██ * ██ ██ ██ ██ * ███████ ██ ███████ ██ */ add_action("list", function() { global $pageindex, $settings; $supported_formats = [ "html", "json", "text" ]; $format = $_GET["format"] ?? "html"; $sorted_pageindex = get_object_vars($pageindex); ksort($sorted_pageindex, SORT_NATURAL); switch($format) { case "html": $title = "All Pages"; $content = "Error: The format '$format' is not currently supported by this action on $settings->sitename. Supported formats: " . implode(", ", $supported_formats) . ".")); } }); /** * @api {get} ?action=list-tags[&tag=] Get a list of tags or pages with a certain tag * @apiDescription Gets a list of all tags on the wiki. Adding the `tag` parameter causes a list of pages with the given tag to be returned instead. * @apiName ListTags * @apiGroup Utility * @apiPermission Anonymous * * @apiParam {string} tag Optional. If provided a list of all the pages with that tag is returned instead. * @apiParam {string} format Optional. If specified sets the format of the returned result. Supported values: html, json. Default: html */ /* * ██ ██ ███████ ████████ ████████ █████ ██████ ███████ * ██ ██ ██ ██ ██ ██ ██ ██ ██ * ██ ██ ███████ ██ █████ ██ ███████ ██ ███ ███████ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ * ███████ ██ ███████ ██ ██ ██ ██ ██████ ███████ */ add_action("list-tags", function() { global $pageindex, $settings; $supported_formats = [ "html", "json", "text" ]; $format = $_GET["format"] ?? "html"; if(!in_array($format, $supported_formats)) { http_response_code(400); exit(page_renderer::render_main("Format error - $settings->sitename", "
Error: The format '$format' is not currently supported by this action on $settings->sitename. Supported formats: " . implode(", ", $supported_formats) . ".")); } if(!isset($_GET["tag"])) { // Render a list of all tags $all_tags = get_all_tags(); sort($all_tags, SORT_NATURAL); switch($format) { case "html": $content = "
(All tags)
\n"; exit(page_renderer::render("$tag - Tag List - $settings->sitename", $content)); case "json": header("content-type: application/json"); exit(json_encode($pagelist, JSON_PRETTY_PRINT)); case "text": header("content-type: text/plain"); exit(implode("\n", $pagelist)); } }); statistic_add([ "id" => "tag-count", "name" => "Number of Tags", "type" => "scalar", "update" => function($old_data) { global $pageindex; $result = new stdClass(); // value, state, completed $result->value = count(get_all_tags()); $result->completed = true; return $result; } ]); statistic_add([ "id" => "tags-per-page", "name" => "Average Number of Tags per Page", "type" => "scalar", "update" => function($old_data) { global $pageindex; $tag_counts = []; foreach($pageindex as $page_entry) $tag_counts[] = count($page_entry->tags ?? []); $result = new stdClass(); // value, state, completed $result->value = round(array_sum($tag_counts) / count($tag_counts), 3); $result->completed = true; return $result; } ]); statistic_add([ "id" => "most-tags", "name" => "Most tags on a single page", "type" => "scalar", "update" => function($old_data) { global $pageindex; $highest_tag_count = 0; $highest_tag_page = ""; foreach($pageindex as $pagename => $page_entry) { if(count($page_entry->tags ?? []) > $highest_tag_count) { $highest_tag_count = count($page_entry->tags ?? []); $highest_tag_page = $pagename; } } $result = new stdClass(); // value, state, completed $result->value = "$highest_tag_count (" . htmlentities($highest_tag_page) . ")"; $result->completed = true; return $result; } ]); statistic_add([ "id" => "untagged-pages", "name" => "Untagged Pages", "type" => "page-list", "update" => function($old_data) { global $pageindex; $untagged_pages = []; foreach($pageindex as $pagename => $page_entry) { if(empty($page_entry->tags) || count($page_entry->tags ?? []) == 0) $untagged_pages[] = $pagename; } sort($untagged_pages, SORT_STRING | SORT_FLAG_CASE); $result = new stdClass(); // value, state, completed $result->value = $untagged_pages; $result->completed = true; return $result; } ]); add_help_section("30-all-pages-tags", "Listing pages and tags", "All the pages and tags on $settings->sitename are listed on a pair of pages to aid navigation. The list of all pages on $settings->sitename can be found by clicking "All Pages" on the top bar. The list of all the tags currently in use can be found by clicking "All Tags" in the "More..." menu in the top right.
Each tag on either page can be clicked, and leads to a list of all pages that possess that particular tag.
Redirect pages are shown in italics. A page's last known editor is also shown next to each entry on a list of pages, along with the last known size (which should correct, unless it was changed outside of $settings->sitename) and the time since the last modification (hovering over this will show the exact time that the last modification was made in a tooltip).
"); } ]); /** * Gets a list of all the tags currently used across the wiki. * @package page-list * @since v0.15 * @return string[] A list of all unique tags present on all pages across the wiki. */ function get_all_tags() { global $pageindex; $all_tags = []; foreach($pageindex as $page_entry) { if(empty($page_entry->tags)) continue; foreach($page_entry->tags as $tag) { if(!in_array($tag, $all_tags)) $all_tags[] = $tag; } } return $all_tags; } /** * Renders a list of pages as HTML. * @package page-list * @param string[] $pagelist A list of page names to include in the list. * @return string The specified list of pages as HTML. */ function generate_page_list($pagelist) { global $pageindex; // ✎ ✎ 🕒 🕒 $result = "Login failed.
\n"; if(isset($_GET["required"])) $content .= "\t\t$settings->sitename requires that you login before continuing.
\n"; $content .= "\t\t\n"; exit(page_renderer::render_main($title, $content)); }); /** * @api {post} ?action=checklogin Perform a login * @apiName CheckLogin * @apiGroup Authorisation * @apiPermission Anonymous * * @apiParam {string} user The user name to login with. * @apiParam {string} pass The password to login with. * @apiParam {string} returnto The URL to redirect to upon a successful login. * * @apiError InvalidCredentialsError The supplied credentials were invalid. Note that this error is actually a redirect to ?action=login&failed=yes (with the returnto parameter appended if you supplied one) */ /* * ██████ ██ ██ ███████ ██████ ██ ██ * ██ ██ ██ ██ ██ ██ ██ * ██ ███████ █████ ██ █████ * ██ ██ ██ ██ ██ ██ ██ * ██████ ██ ██ ███████ ██████ ██ ██ * * ██ ██████ ██████ ██ ███ ██ * ██ ██ ██ ██ ██ ████ ██ * ██ ██ ██ ██ ███ ██ ██ ██ ██ * ██ ██ ██ ██ ██ ██ ██ ██ ██ * ███████ ██████ ██████ ██ ██ ████ */ add_action("checklogin", function() { global $settings, $env; // Actually do the login if(isset($_POST["user"]) and isset($_POST["pass"])) { // The user wants to log in $user = $_POST["user"]; $pass = $_POST["pass"]; if(!empty($settings->users->$user) && verify_password($pass, $settings->users->$user->password)) { // Success! :D // Update the environment $env->is_logged_in = true; $env->user = $user; $env->user_data = $settings->users->{$env->user}; $new_password_hash = hash_password_update($pass, $settings->users->$user->password); // Update the password hash if($new_password_hash !== null) { $env->user_data->password = $new_password_hash; if(!save_userdata()) { http_response_code(503); exit(page_renderer::render_main("Login Error - $settings->sitename", "Your credentials were correct, but $settings->sitename was unable to log you in as an updated hash of your password couldn't be saved. Updating your password hash to the latest and strongest hashing algorithm is an important part of keeping your account secure.
Please contact $settings->admindetails_name, $settings->sitename's adminstrator, for assistance (their email address can be found at the bottom of every page, including this one).
")); } error_log("[Pepperminty Wiki] Updated password hash for $user."); } $_SESSION["$settings->sessionprefix-user"] = $user; $_SESSION["$settings->sessionprefix-pass"] = $new_password_hash ?? hash_password($pass); $_SESSION["$settings->sessionprefix-expiretime"] = time() + 60*60*24*30; // 30 days from now // Redirect to wherever the user was going http_response_code(302); header("x-login-success: yes"); if(isset($_GET["returnto"])) header("location: " . $_GET["returnto"]); else header("location: index.php"); exit(); } else { // Login failed :-( http_response_code(302); header("x-login-success: no"); $nextUrl = "index.php?action=login&failed=yes"; if(!empty($_GET["returnto"])) $nextUrl .= "&returnto=" . rawurlencode($_GET["returnto"]); header("location: $nextUrl"); exit(); } } else { http_response_code(302); $nextUrl = "index.php?action=login&failed=yes&badrequest=yes"; if(!empty($_GET["returnto"])) $nextUrl .= "&returnto=" . rawurlencode($_GET["returnto"]); header("location: $nextUrl"); exit(); } }); add_action("hash-cost-test", function() { global $env; header("content-type: text/plain"); if(!$env->is_logged_in || !$env->is_admin) { http_response_code(401); exit("Error: Only moderators are allowed to use this action."); } $time_compute = microtime(true); $cost = hash_password_compute_cost(true); $time_compute = (microtime(true) - $time_compute)*1000; $time_cost = microtime(true); password_hash("testing", PASSWORD_DEFAULT, [ "cost" => $cost ]); $time_cost = (microtime(true) - $time_cost)*1000; echo("Calculated cost: $cost ({$time_cost}ms)\n"); echo("Time taken: {$time_compute}ms\n"); exit(date("r")); }); // Register a section on logging in on the help page. add_help_section("30-login", "Logging in", "In order to edit $settings->sitename and have your edit attributed to you, you need to be logged in. Depending on the settings, logging in may be a required step if you want to edit at all. Thankfully, loggging in is not hard. Simply click the "Login" link in the top left, type your username and password, and then click login.
If you do not have an account yet and would like one, try contacting $settings->admindetails_name, $settings->sitename's administrator and ask them nicely to see if they can create you an account.
"); // Re-check the password hashing cost, if necessary do_password_hash_code_update(); } ]); /** * Recalculates and updates the password hashing cost. */ function do_password_hash_code_update() { global $settings, $paths; // There's no point if we're using Argon2i, as it doesn't take a cost if(hash_password_properties()["algorithm"] == PASSWORD_ARGON2I) return; // Skip rechecking if the automatic check has been disabled if($settings->password_cost_time_interval == -1) return; // Skip the recheck if we've done one recently if(isset($settings->password_cost_time_lastcheck) && time() - $settings->password_cost_time_lastcheck < $settings->password_cost_time_interval) return; $new_cost = hash_password_compute_cost(); // Save the new cost, but only if it's higher than the old one if($new_cost > $settings->password_cost) $settings->password_cost = $new_cost; // Save the current time in the settings $settings->password_cost_time_lastcheck = time(); file_put_contents($paths->settings_file, json_encode($settings, JSON_PRETTY_PRINT)); } /** * Figures out the appropriate algorithm & options for hashing passwords based * on the current settings. * @return array The appropriate password hashing algorithm and options. */ function hash_password_properties() { global $settings; $result = [ "algorithm" => constant($settings->password_algorithm), "options" => [ "cost" => $settings->password_cost ] ]; if(defined("PASSWORD_ARGON2I") && $result["algorithm"] == PASSWORD_ARGON2I) $result["options"] = []; return $result; } /** * Hashes the given password according to the current settings defined * in $settings. * @package page-login * @param string $pass The password to hash. * * @return string The hashed password. Uses sha3 if $settings->use_sha3 is * enabled, or sha256 otherwise. */ function hash_password($pass) { $props = hash_password_properties(); return password_hash( base64_encode(hash("sha384", $pass)), $props["algorithm"], $props["options"] ); } /** * Verifies a user's password against a pre-generated hash. * @param string $pass The user's password. * @param string $hash The hash to compare against. * @return bool Whether the password matches the has or not. */ function verify_password($pass, $hash) { $pass_transformed = base64_encode(hash("sha384", $pass)); return password_verify($pass_transformed, $hash); } /** * Determines if the provided password needs re-hashing or not. * @param string $pass The password to check. * @param string $hash The hash of the provided password to check. * @return string|null Returns null if an updaste is not required - otherwise returns the new updated hash. */ function hash_password_update($pass, $hash) { $props = hash_password_properties(); if(password_needs_rehash($hash, $props["algorithm"], $props["options"])) { return hash_password($pass); } return null; } /** * Computes the appropriate cost value for password_hash based on the settings * automatically. * Starts at 10 and works upwards in increments of 1. Goes on until a value is * found that's greater than the target - or 10x the target time elapses. * @return integer The automatically calculated password hashing cost. */ function hash_password_compute_cost($verbose = false) { global $settings; $props = hash_password_properties(); if($props["algorithm"] == PASSWORD_ARGON2I) return null; $props["options"]["cost"] = 10; $target_cost_time = $settings->password_cost_time / 1000; // The setting is in ms do { $props["options"]["cost"]++; $start_i = microtime(true); password_hash("testing", $props["algorithm"], $props["options"]); $end_i = microtime(true); if($verbose) echo("Attempt | cost = {$props["options"]["cost"]}, time = " . ($end_i - $start_i)*1000 . "ms\n"); // Iterate until we find a cost high enough // ....but don't keep going forever - try for at most 10x the target // time in total (in case the specified algorithm doesn't take a // cost parameter) } while($end_i - $start_i < $target_cost_time); return $props["options"]["cost"]; } register_module([ "name" => "Logout", "version" => "0.6.1", "author" => "Starbeamrainbowlabs", "description" => "Adds an action to let users user out. For security reasons it is wise to add this module since logging in automatically opens a session that is valid for 30 days.", "id" => "page-logout", "code" => function() { /** * @api {post} ?action=logout Logout * @apiDescription Logout. Make sure that your bot requests this URL when it is finished - this call not only clears your cookies but also clears the server's session file as well. Note that you can request this when you are already logged out and it will completely wipe your session on the server. * @apiName Logout * @apiGroup Authorisation * @apiPermission Anonymous */ /* * ██ ██████ ██████ ██████ ██ ██ ████████ * ██ ██ ██ ██ ██ ██ ██ ██ ██ * ██ ██ ██ ██ ███ ██ ██ ██ ██ ██ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ * ███████ ██████ ██████ ██████ ██████ ██ */ add_action("logout", function() { global $env; $env->is_logged_in = false; unset($env->user); unset($env->user_data); //clear the session variables $_SESSION = []; session_destroy(); exit(page_renderer::render_main("Logout Successful", "Logout Successful. You can login again here.
")); }); } ]); register_module([ "name" => "Page mover", "version" => "0.9.3", "author" => "Starbeamrainbowlabs", "description" => "Adds an action to allow administrators to move pages.", "id" => "page-move", "code" => function() { global $settings; /** * @api {get} ?action=move[&new_name={newPageName}] Move a page * @apiName Move * @apiGroup Page * @apiPermission Moderator * * @apiParam {string} new_name The new name to move the page to. If not set a page will be returned containing a move page form. * * @apiUse UserNotModeratorError * @apiError EditingDisabledError Editing is disabled on this wiki, so pages can't be moved. * @apiError PageExistsAtDestinationError A page already exists with the specified new name. * @apiError NonExistentPageError The page you're trying to move doesn't exist in the first place. * @apiError PreExistingFileError A pre-existing file on the server's file system was detected. */ /* * ███ ███ ██████ ██ ██ ███████ * ████ ████ ██ ██ ██ ██ ██ * ██ ████ ██ ██ ██ ██ ██ █████ * ██ ██ ██ ██ ██ ██ ██ ██ * ██ ██ ██████ ████ ███████ */ add_action("move", function() { global $pageindex, $settings, $env, $paths; if(!$settings->editing) { exit(page_renderer::render_main("Moving $env->page - error", "You tried to move $env->page, but editing is disabled on this wiki.
If you wish to move this page, please re-enable editing on this wiki first.
Nothing has been changed.
")); } if(!$env->is_admin) { exit(page_renderer::render_main("Moving $env->page - Error", "You tried to move $env->page, but you do not have permission to do that.
You should try logging in as an admin.
")); } if(!isset($_GET["new_name"]) or strlen($_GET["new_name"]) == 0) exit(page_renderer::render_main("Moving $env->page", "You tried to move $env->page to $new_name, but the page with the name $env->page does not exist in the first place.
Nothing has been changed.
")); if($env->page == $new_name) exit(page_renderer::render_main("Moving $env->page - Error", "You tried to move $page, but the new name you gave is the same as it's current name.
It is possible that you tried to use some characters in the new name that are not allowed and were removed.
Page names may not contain any of these characters: ?%*:|\"><()[]
Whilst moving the file associated with $env->page, $settings->sitename detected a pre-existing file on the server's file system. Because $settings->sitename can't determine whether the existing file is important to another component of $settings->sitename or it's host web server, the move have been aborted - just in case.
If you know that this move is actually safe, please get your site administrator (" . $settings->admindetails_name . ") to perform the move manually. Their contact address can be found at the bottom of every page (including this one).
")); // Move the page in the page index $pageindex->$new_name = new stdClass(); foreach($pageindex->$page as $key => $value) { $pageindex->$new_name->$key = $value; } unset($pageindex->$page); $pageindex->$new_name->filename = "$new_name.md"; // If this page has an associated file, then we should move that too if(!empty($pageindex->$new_name->uploadedfile)) { // Update the filepath to point to the description and not the image $pageindex->$new_name->filename = $pageindex->$new_name->filename . ".md"; // Move the file in the pageindex $pageindex->$new_name->uploadedfilepath = $new_name; // Move the file on disk rename($env->storage_prefix . $env->page, $env->storage_prefix . $new_name); } // Come to think about it, we should probably move the history while we're at it foreach($pageindex->$new_name->history as &$revisionData) { // We're only interested in edits if($revisionData->type !== "edit") continue; $newRevisionName = $pageindex->$new_name->filename . ".r$revisionData->rid"; // Move the revision to it's new name rename( $env->storage_prefix . $revisionData->filename, $env->storage_prefix . $newRevisionName ); // Update the pageindex entry $revisionData->filename = $newRevisionName; } // Save the updated pageindex file_put_contents($paths->pageindex, json_encode($pageindex, JSON_PRETTY_PRINT)); // Move the page on the disk rename("$env->storage_prefix$env->page.md", "$env->storage_prefix$new_name.md"); // Move the page in the id index ids::movepagename($page, $new_name); // Move the comments file as well, if it exists if(file_exists("$env->storage_prefix$env->page.comments.json")) { rename( "$env->storage_prefix$env->page.comments.json", "$env->storage_prefix$new_name.comments.json" ); } // Add a recent change announcing the move if the recent changes // module is installed if(module_exists("feature-recent-changes")) { add_recent_change([ "type" => "move", "timestamp" => time(), "oldpage" => $page, "page" => $new_name, "user" => $env->user ]); } // Exit with a nice message exit(page_renderer::render_main("Moving " . htmlentities($env->page), "" . htmlentities($env->page) . " has been moved to " . htmlentities($new_name) . " successfully.
")); }); // Register a help section add_help_section("60-move", "Moving Pages", "If you are logged in as an administrator, then you have the power to move pages. To do this, click "Move" in the "More..." menu when browsing the pge you wish to move. Type in the new name of the page, and then click "Move Page".
"); } ]); register_module([ "name" => "Update", "version" => "0.6.2", "author" => "Starbeamrainbowlabs", "description" => "Adds an update page that downloads the latest stable version of Pepperminty Wiki. This module is currently outdated as it doesn't save your module preferences.", "id" => "page-update", "code" => function() { /** * @api {get} ?action=update[do=yes] Update the wiki * @apiDescription Update the wiki by downloading a new version of Pepperminty Wiki from the URL specified in the settings. Note that unless you change the url from it's default, all custom modules installed will be removed. **Note also that this plugin is currently out of date. Use with extreme caution!** * @apiName Update * @apiGroup Utility * @apiPermission Moderator * * @apiParam {string} do Set to 'yes' to actually do the upgrade. Omission causes a page asking whether an update is desired instead. * @apiParam {string} secret The wiki's secret string that's stored in the settings. * * @apiUse UserNotModeratorError * @apiParam InvalidSecretError The supplied secret doesn't match up with the secret stored in the wiki's settings. */ /* * ██ ██ ██████ ██████ █████ ████████ ███████ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ * ██ ██ ██████ ██ ██ ███████ ██ █████ * ██ ██ ██ ██ ██ ██ ██ ██ ██ * ██████ ██ ██████ ██ ██ ██ ███████ */ add_action("update", function() { global $settings, $env; if(!$env->is_admin) { http_response_code(401); exit(page_renderer::render_main("Update - Error", "You must be an administrator to do that.
")); } if(!isset($_GET["do"]) or $_GET["do"] !== "true" or $_GET["do"] !== "yes") { exit(page_renderer::render_main("Update $settings->sitename", "This page allows you to update $settings->sitename.
Currently, $settings->sitename is using $settings->version of Pepperminty Wiki.
This script will automatically download and install the latest version of Pepperminty Wiki from the url of your choice (see settings), regardless of whether an update is actually needed (version checking isn't implemented yet).
To update $settings->sitename, fill out the form below and click click the update button.
Note that a backup system has not been implemented yet! If this script fails you will loose your wiki's code and have to re-build it.
")); } if(!isset($_GET["secret"]) or $_GET["secret"] !== $settings->sitesecret) { exit(page_renderer::render_main("Update $settings->sitename - Error", "You forgot to enter $settings->sitename's secret code or entered it incorrectly. $settings->sitename's secret can be found in the settings portion of index.php
.
" . __FILE__ . "
.\n";
$oldcode = file_get_contents(__FILE__);
$log .= "Fetching new code...";
$newcode = file_get_contents($settings->updateurl);
$log .= "done.\n";
$log .= "Rewriting " . __FILE__ . "
...";
$settings = substr($oldcode, 0, strpos($oldcode, $settings_separator));
$code = substr($newcode, strpos($newcode, $settings_separator));
$result = $settings . $code;
$log .= "done.\n";
$log .= "Saving...";
file_put_contents(__FILE__, $result);
$log .= "done.\n";
$log .= "Update complete. I am now running on the latest version of Pepperminty Wiki.";
$log .= "The version number that I have updated to can be found on the credits or help ages.";
exit(page_renderer::render_main("Update - Success", "Although you can use the edit page to view a page's source, you can also ask $settings->sitename to send you the raw page source and nothing else. This feature is intented for those who want to automate their interaction with $settings->sitename.
To use this feature, navigate to the page for which you want to see the source, and then alter the action
parameter in the url's query string to be raw
. If the action
parameter doesn't exist, add it. Note that when used on an file's page this action will return the source of the description and not the file itself.
$env->page does not exist.
Since editing is currently disabled on this wiki, you may not create this page. If you feel that this page should exist, try contacting this wiki's Administrator.
")); } } header("last-modified: " . gmdate('D, d M Y H:i:s T', $pageindex->{$env->page}->lastmodified)); // Perform a redirect if the requested page is a redirect page if(isset($pageindex->$page->redirect) && $pageindex->$page->redirect === true) { $send_redirect = true; if(isset($_GET["redirect"]) && $_GET["redirect"] == "no") $send_redirect = false; if($send_redirect) { // TODO: Send an explanatory page along with the redirect http_response_code(307); $redirectUrl = "?action=$env->action&redirected_from=" . rawurlencode($env->page); $hashCode = ""; $newPage = $pageindex->$page->redirect_target; if(strpos($newPage, "#") !== false) { // Extract the part after the hash symbol $hashCode = substr($newPage, strpos($newPage, "#") + 1); // Remove the hash from the new page name $newPage = substr($newPage, 0, strpos($newPage, "#")); } $redirectUrl .= "&page=" . rawurlencode($newPage); if(!empty($pageindex->$newPage->redirect)) $redirectUrl .= "&redirect=no"; if(strlen($hashCode) > 0) $redirectUrl .= "#$hashCode"; header("location: $redirectUrl"); exit(); } } $title = "$env->page - $settings->sitename"; if(isset($pageindex->$page->protect) && $pageindex->$page->protect === true) $title = $settings->protectedpagechar . $title; $content = ""; if(!$env->is_history_revision) $content .= "(Revision saved by {$env->history->revision_data->editor} " . render_timestamp($env->history->revision_data->timestamp) . ". Jump to the current revision or see a list of all revisions for this page.)
\n"; } // Add a visit parent page link if we're a subpage if(get_page_parent($env->page) !== false) $content .= "« " . htmlentities(get_page_parent($env->page)) . "
\n"; // Add an extra message if the requester was redirected from another page if(isset($_GET["redirected_from"])) $content .= "Redirected from " . $_GET["redirected_from"] . ".
\n"; $parsing_start = microtime(true); $rawRenderedSource = parse_page_source(file_get_contents($env->page_filename)); $content .= $rawRenderedSource; if(!empty($pageindex->$page->tags)) { $content .= " \n"; } /*else { $content .= "\n"; }*/ if($settings->show_subpages) { $subpages = get_object_vars(get_subpages($pageindex, $env->page)); if(count($subpages) > 0) { $content .= "" . $settings->footer_message; // Add the last edited time to the footer $mode = isset($_GET["mode"]) ? strtolower(trim($_GET["mode"])) : "normal"; switch($mode) { case "contentonly": // Content only mode: Send only the content of the page exit($content); case "parsedsourceonly": // Parsed source only mode: Send only the raw rendered source exit($rawRenderedSource); case "printable": // Printable mode: Sends a printable version of the page exit(page_renderer::render_minimal($title, $content)); case "normal": default: // Normal mode: Send a normal page exit(page_renderer::render_main($title, $content)); } }); } ]); register_module([ "name" => "Parsedown", "version" => "0.9.13", "author" => "Emanuil Rusev & Starbeamrainbowlabs", "description" => "An upgraded (now default!) parser based on Emanuil Rusev's Parsedown Extra PHP library (https://github.com/erusev/parsedown-extra), which is licensed MIT. Please be careful, as this module adds some weight to your installation, and also *requires* write access to the disk on first load.", "id" => "parser-parsedown", "code" => function() { global $settings; $parser = new PeppermintParsedown(); $parser->setInternalLinkBase("?page=%s"); add_parser("parsedown", function($source) use ($parser) { global $settings; $parser->setMarkupEscaped($settings->clean_raw_html); $result = $parser->text($source); return $result; }, function($source) { global $version, $settings, $pageindex; $id_text = "$version|$settings->parser|$source"; // Find template includes preg_match_all( '/\{\{\s*([^|]+)\s*(?:\|[^}]*)?\}\}/', $source, $includes ); foreach($includes[1] as $include_pagename) { if(empty($pageindex->$include_pagename)) $id_text .= "|$include_pagename:" . parsedown_pagename_resolve( $pageindex->$include_pagename->lastmodified ); } return str_replace(["+","/"], ["-","_"], base64_encode(hash( "sha256", $id_text, true ))); }); /* * ███████ ████████ █████ ████████ ██ ███████ ████████ ██ ██████ ███████ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ * ███████ ██ ███████ ██ ██ ███████ ██ ██ ██ ███████ * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ * ███████ ██ ██ ██ ██ ██ ███████ ██ ██ ██████ ███████ */ statistic_add([ "id" => "wanted-pages", "name" => "Wanted Pages", "type" => "page", "update" => function($old_stats) { global $pageindex, $env; $result = new stdClass(); // completed, value, state $pages = []; foreach($pageindex as $pagename => $pagedata) { if(!file_exists($env->storage_prefix . $pagedata->filename)) continue; $page_content = file_get_contents($env->storage_prefix . $pagedata->filename); $page_links = PeppermintParsedown::extract_page_names($page_content); foreach($page_links as $linked_page) { // We're only interested in pages that don't exist if(!empty($pageindex->$linked_page)) continue; if(empty($pages[$linked_page])) $pages[$linked_page] = 0; $pages[$linked_page]++; } } arsort($pages); $result->value = $pages; $result->completed = true; return $result; }, "render" => function($stats_data) { $result = "
Page Name | Linking Pages |
---|---|
$pagename | $linking_pages |
Page Name | Linking Pages |
---|---|
$pagename_display | $link_count |
$settings->sitename's editor uses an extended version of Parsedown to render pages, which is a fantastic open source Github flavoured markdown parser. You can find a quick reference guide on Github flavoured markdown here by adam-p, or if you prefer a book Mastering Markdown by KB is a good read, and free too!
# Heading Name {#HeadingId}
. Then you can link to like like this: [[Page name#HeadingId}]]
. You can also link to a heading id on the current page by omitting the page name: [[#HeadingId]]
.$settings->sitename's editor also supports some extra custom syntax, some of which is inspired by Mediawiki.
Type this | To get this | Comments |
---|---|---|
[[Internal link]] | Internal Link | An internal link. |
[[Display Text|Internal link]] | Display Text | An internal link with some display text. |
![Alt text](http://example.com/path/to/image.png | 256x256 | right) | An image floating to the right of the page that fits inside a 256px x 256px box, preserving aspect ratio. | |
![Alt text](http://example.com/path/to/image.png | 256x256 | caption) | An image with a caption that fits inside a 256px x 256px box, preserving aspect ratio. The presence of the word caption in the regular braces causes the alt text to be taken and displayed below the image itself. | |
![Alt text](Files/Cheese.png) | An example of the short url syntax for images. Simply enter the page name of an image (or video / audio file), and Pepperminty Wiki will sort out the url for you. |
Note that the all image image syntax above can be mixed and matched to your liking. The caption
option in particular must come last or next to last.
$settings->sitename also supports including one page in another page as a template. The syntax is very similar to that of Mediawiki. For example, {{Announcement banner}}
will include the contents of the \"Announcement banner\" page, assuming it exists.
You can also use variables. Again, the syntax here is very similar to that of Mediawiki - they can be referenced in the included page by surrrounding the variable name in triple curly braces (e.g. {{{Announcement text}}}
), and set when including a page with the bar syntax (e.g. {{Announcement banner | importance = high | text = Maintenance has been planned for tonight.}}
). Currently the only restriction in templates and variables is that you may not include a closing curly brace (}
) in the page name, variable name, or value.
$settings->sitename also supports a number of special built-in variables. Their syntax and function are described below:
Type this | To get this |
---|---|
{{{@}}} | Lists all variables and their values in a table. |
{{{#}}} | Shows a 'stack trace', outlining all the parent includes of the current page being parsed. |
{{{~}}} | Outputs the requested page's name. |
{{{*}}} | Outputs a comma separated list of all the subpages of the current page. |
{{{+}}} | Shows a gallery containing all the files that are sub pages of the current page. |
Key | Value |
---|---|
" . $this->escapeText($key) . " | " . $this->escapeText($value) . " |
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")); } ////////////////////////////////// /// Final Consistency Measures /// ////////////////////////////////// if(!isset($pageindex->{$env->page}) && isset($pageindex->{ucwords($env->page)})) { http_response_code(307); header("location: ?page=" . ucwords($env->page)); header("content-type: text/plain"); exit("$env->page doesn't exist on $settings->sitename, but " . ucwords($env->page) . " does. You should be redirected there automatically."); } // 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_minimal("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.
")); } ////////////////////////////////// // 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?
")); } ?>
" . page_renderer::render_username($comment->username ?? "Unknown") . " said:
"; $result .= "\t"; $result .= "\t\t\n"; if($env->user == $comment->username || $env->is_admin) $result .= "Delete\n"; $result .= "\t\t🔗\n"; $result .= "\t\t\n"; $result .= "\t
\n"; $result .= "\t" . render_comments($comment->replies, $depth + 1) . "\n"; $result .= "\t