From 9ed0d666f945a6e3e0be0d6c0ab1b69b68f43457 Mon Sep 17 00:00:00 2001 From: Isabeau Kisler Date: Sun, 28 May 2017 10:48:59 -0700 Subject: [PATCH 1/3] Fix repeated page names on sidebar Check if page already appears in sidebar. If so, continue the loop --- build/index.php | 7139 +++++++++++++++++++------------------ modules/extra-sidebar.php | 5 +- 2 files changed, 3575 insertions(+), 3569 deletions(-) diff --git a/build/index.php b/build/index.php index 7bd15a0..ca4713a 100644 --- a/build/index.php +++ b/build/index.php @@ -2,351 +2,351 @@ $start_time = microtime(true); mb_internal_encoding("UTF-8"); - -/* - * Pepperminty Wiki - * ================ - * Inspired by Minty Wiki by am2064 - * Link: https://github.com/am2064/Minty-Wiki - * - * Credits: - * Code by @Starbeamrainbowlabs - * Parsedown - by erusev and others on github from http://parsedown.org/ - * Mathematical Expression rendering - * Code: @con-f-use - * Rendering: MathJax (https://www.mathjax.org/) - * Bug reports: - * #2 - Incorrect closing tag - nibreh - * #8 - Rogue tag - nibreh - */ -$guiConfig = <<<'GUICONFIG' -{ - "sitename": {"type": "text", "description": "Your wiki's name.", "default": "Pepperminty Wiki"}, - "defaultpage": {"type": "text", "description": "The name of the page that will act as the home page for the wiki. This page will be served if you don't specify a page.", "default": "Main Page"}, - "admindetails_name": {"type": "text", "description": "Your name as the wiki administrator.", "default": "Administrator"}, - "admindetails_email": {"type": "email", "description": "Your email address as the wiki administrator. Will be displayed as a support contact address.", "default": "admin@localhost"}, - "favicon": {"type": "url", "description": "A url that points to the favicon you want to use for your wiki. This image By default this is set to a data: url of a Peppermint (Credit: by bluefrog23, source: https://openclipart.org/detail/19571/peppermint-candy-by-bluefrog23)", "default": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAB3VBMVEXhERHbKCjeVVXjb2/kR0fhKirdHBziDg6qAADaHh7qLy/pdXXUNzfMAADYPj7ZPDzUNzfbHx/fERHpamrqMTHgExPdHx/bLCzhLS3fVFTjT0/ibm7kRkbiLi7aKirdISHeFBTqNDTpeHjgERHYJCTVODjYQkLaPj6/AADVOTnpbW3cIyPdFRXcJCThMjLiTU3ibW3fVVXaKyvcERH4ODj+8fH/////fHz+Fxf4KSn0UFD/CAj/AAD/Xl7/wMD/EhL//v70xMT/+Pj/iYn/HBz/g4P/IyP/Kyv/7Oz0QUH/9PT/+vr/ior/Dg7/vr7/aGj/QED/bGz/AQH/ERH/Jib/R0f/goL/0dH/qan/YWH/7e3/Cwv4R0f/MTH/enr/vLz/u7v/cHD/oKD/n5//aWn+9/f/k5P/0tL/trb/QUH/cXH/dHT/wsL/DQ3/p6f/DAz/1dX/XV3/kpL/i4v/Vlb/2Nj/9/f/pKT+7Oz/V1f/iIj/jIz/r6//Zmb/lZX/j4//T0//Dw/4MzP/GBj/+fn/o6P/TEz/xMT/b2//Tk7/OTn/HR3/hIT/ODj/Y2P/CQn/ZGT/6Oj0UlL/Gxv//f3/Bwf/YmL/6+v0w8P/Cgr/tbX0QkL+9fX4Pz/qNzd0dFHLAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfeCxINNSdmw510AAAA5ElEQVQYGQXBzSuDAQCA8eexKXOwmSZepa1JiPJxsJOrCwcnuchBjg4O/gr7D9zk4uAgJzvuMgcTpYxaUZvSm5mUj7TX7ycAqvoLIJBwStVbP0Hom1Z/ejoxrbaR1Jz6nWinbKWttGRgMSSjanPktRY6mB9WtRNTn7Ilh7LxnNpKq2/x5LnBitfz+hx0qxUaxhZ6vwqq9bx6f2XXvuUl9SVQS38NR7cvln3v15tZ9bQpuWDtZN3Lgh5DWJex3Y+z1KrVhw21+CiM74WZo83DiXq0dVBDYNJkFEU7WrwDAZhRtQrwDzwKQbT6GboLAAAAAElFTkSuQmCC"}, - "logo_url": {"type": "url", "description": "A url that points to the site's logo. Leave blank to disable. When enabled the logo will be inserted next to the site name on every page.", "default": "//starbeamrainbowlabs.com/images/logos/peppermint.png"}, - "logo_position": {"type": "text", "description": "The side of the site name at which the logo should be placed.", "default": "left"}, - "show_subpages": {"type": "text", "description": "Whether to show a list of subpages at the bottom of the page.", "default": true}, - "subpages_display_depth": {"type": "text", "description": "The depth to which we should display when listing subpages at the bottom the page.", "default": 3}, - "footer_message": {"type": "textarea", "description": "A message that will appear at the bottom of every page. May contain HTML.", "default": "All content is under this license. Please make sure that you read and understand the license, especially if you are thinking about copying some (or all) of this site's content, as it may restrict you from doing so."}, - "editing_message": {"type": "textarea", "description": "A message that will appear just before the submit button on the editing page. May contain HTML.", "default": "Formatting help (Markdown Cheatsheet)
\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"}, - "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 sha256 (or sha3 if you have that option turned on)", "default": { - "admin": { - "email": "admin@somewhere.com", - "password": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" - }, - "user": { - "email": "example@example.net", - "password": "873ac9ffea4dd04fa719e8920cd6938f0c23cd678af330939cff53c3d2855f34" - } - }}, - "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": "⚙ " }, - "use_sha3": {"type": "checkbox", "description": "Whether to use the new sha3 hashing algorithm for passwords etc.", "default": false }, - "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.
", "default": [ - "user-status", - [ - "Home", - "index.php" - ], - "search", - [ - "Read", - "index.php?page={page}" - ], - [ - "Edit", - "index.php?action=edit&page={page}" - ], - [ - "All Pages", - "index.php?action=list" - ], - "menu" - ]}, - "nav_links_extra": {"type": "nav", "description": "An array of additional links in the above format that will be shown under \"More\" subsection.", "default": [ - [ - "🕓 Page History", - "?action=history&page={page}" - ], - [ - "🎫 All Tags", - "index.php?action=list-tags" - ], - [ - "⇝ Random Page", - "?action=random" - ], - [ - "Recent changes", - "?action=recent-changes" - ], - [ - "🡅 Upload", - "index.php?action=upload" - ], - [ - "⌧ ◆Delete", - "index.php?action=delete&page={page}" - ], - [ - "⎘ ◆Move", - "index.php?action=move&page={page}" - ], - [ - "🔐 ◆Toggle Protection", - "index.php?action=protect&page={page}" - ], - [ - "⚙ ◆Edit master settings", - "index.php?action=configure" - ] - ]}, - "nav_links_bottom": {"type": "nav", "description": "An array of links in the above format that will be shown at the bottom of the page.", "default": [ - [ - "🖶 Printable version", - "index.php?action=view&mode=printable&page={page}" - ], - [ - "Credits", - "index.php?action=credits" - ], - [ - "Help", - "index.php?action=help" - ] - ]}, - "comment_max_length": {"type": "number", "description": "The maximum allowed length, in characters, for comments", "default": 5000 }, - "comment_min_length": {"type": "number", "description": "The minimum allowed length, in characters, for comments", "default": 10 }, - "comment_time_icon": {"type": "text", "description": "The icon to show next to the time that a comment was posted.", "default": "🕗" }, - "upload_enabled": {"type": "checkbox", "description": "Whether to allow uploads to the server.", "default": true}, - "upload_allowed_file_types": {"type": "array", "description": "An array of mime types that are allowed to be uploaded.", "default": [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/svg+xml", - "video/mp4", - "video/webm", - "audio/mp4", - "audio/mpeg" - ]}, - "preview_file_type": {"type": "text", "description": "The default file type for previews.", "default": "image/png"}, - "default_preview_size": {"type": "number", "description": "The default size of preview images in pixels.", "default": 640}, - "mime_extension_mappings_location": {"type": "text", "description": "The location of a file that maps mime types onto file extensions and vice versa. Used to generate the file extension for an uploaded file. See the configuration guide for windows instructions.", "default": "/etc/mime.types"}, - "mime_mappings_overrides": {"type": "map", "description": "Override mappings to convert mime types into the appropriate file extension. Used to override the above file if it assigns weird extensions to any mime types.", "default": { - "text/plain": "txt", - "audio/mpeg": "mp3" - }}, - "min_preview_size": {"type": "number", "description": "The minimum allowed size of generated preview images in pixels.", "default": 1}, - "max_preview_size": {"type": "number", "description": "The maximum allowed size of generated preview images in pixels.", "default": 2048}, - "search_characters_context": {"type": "number", "description": "The number of characters that should be displayed either side of a matching term in the context below each search result.", "default": 200}, - "search_title_matches_weighting": {"type": "number", "description": "The weighting to give to search term matches found in a page's title.", "default": 10}, - "search_tags_matches_weighting": {"type": "number", "description": "The weighting to give to search term matches found in a page's tags.", "default": 3}, - "dynamic_page_suggestion_count": {"type": "number", "description": "The number of dynamic page name suggestions to fetch from the server when typing in the page search box. Note that lowering this number doesn't really improve performance. Set to 0 to disable.", "default": 7 }, - "defaultaction": {"type": "text", "description": "The default action. This action will be performed if no other action is specified. It is recommended you set this to \"view\" - that way the user automatically views the default page (see above).", "default": "view"}, - "updateurl": {"type": "url", "description": "The url from which to fetch updates. Defaults to the master (development) branch. MAKE SURE THAT THIS POINTS TO A *HTTPS* URL, OTHERWISE SOMEONE COULD INJECT A VIRUS INTO YOUR WIKI!", "default": "https://raw.githubusercontent.com/sbrl/pepperminty-wiki/master/index.php"}, - "optimize_pages": {"type": "checkbox", "description": "Whether to optimise all webpages generated.", "default": true}, - "max_recent_changes": {"type": "number", "description": "The maximum number of recent changes to display on the recent changes page.", "default": 512}, - "export_allow_only_admins": {"type": "checkbox", "description": "Whether to only allow adminstrators to export the your wiki as a zip using the page-export module.", "default": false}, - "sessionprefix": {"type": "text", "description": "You shouldn't need to change this. The prefix that should be used in the names of the session variables. Defaults to \"auto\", which automatically generates this field. See the readme for more information.", "default": "auto"}, - "sessionlifetime": { "type": "number", "description": "Again, you shouldn't need to change this under normal circumstances. This setting controls the lifetime of a login session. Defaults to 24 hours, but it may get cut off sooner depending on the underlying PHP session lifetime.", "default": 86400 }, - "css": {"type": "textarea", "description": "A string of css to include. Will be included in the <head> of every page inside a <style> tag. This may also be an absolute url - urls will be referenced via a <link rel='stylesheet' /> tag.", "default": "auto"} -} -GUICONFIG; - -$settingsFilename = "peppermint.json"; - -$guiConfig = json_decode($guiConfig); -$settings = new stdClass(); -if(!file_exists($settingsFilename)) -{ - // Copy the default settings over to the main settings array - foreach ($guiConfig as $key => $value) - $settings->$key = $value->default; - // Generate a random secret - $settings->secret = bin2hex(openssl_random_pseudo_bytes(16)); - file_put_contents("peppermint.json", json_encode($settings, JSON_PRETTY_PRINT)); -} -else - $settings = json_decode(file_get_contents("peppermint.json")); - -if($settings === null) -{ - header("content-type: text/plain"); - exit("Error: Failed to decode the settings file! Does it contain a syntax error?"); -} - -// Fill in any missing properties -$settingsUpgraded = false; -foreach($guiConfig as $key => $propertyData) -{ - if(!isset($settings->$key)) - { - $settings->$key = $propertyData->default; - $settingsUpgraded = true; - } -} -if($settingsUpgraded) - file_put_contents("peppermint.json", json_encode($settings, JSON_PRETTY_PRINT)); - -// Insert the default CSS if requested -$defaultCSS = << span { flex: 1; text-align: center; line-height: 2; display: inline-block; margin: 0; padding: 0.3rem 0.5rem; border-left: 3px solid #442772; border-right: 3px solid #442772; } -nav:not(.nav-more-menu) a { text-decoration: none; font-weight: bolder; color: inherit; } -.nav-divider { color: transparent; } - -.nav-more { position: relative; background-color: #442772; min-width: 10em; } -.nav-more label { cursor: pointer; } -.nav-more-menu { display: none; z-index: 10000; position: absolute; flex-direction: column; top: 2.6rem; right: -0.2rem; background-color: #8a62a7; border-top: 3px solid #442772; border-bottom: 3px solid #442772; } -input[type=checkbox]:checked ~ .nav-more-menu { display: block; box-shadow: 0.4rem 0.4rem 1rem 0 rgba(50, 50, 50, 0.5); } -.nav-more-menu span { min-width: 10rem; } - -.inflexible { flex: none; } -.off-screen { position: absolute; top: -1000px; left: -1000px;} - -input[type=search] { width: 14rem; padding: 0.3rem 0.4rem; font-size: 1rem; color: white; background: rgba(255, 255, 255, 0.4); border: 0; border-radius: 0.3rem; } -input[type=search]::-webkit-input-placeholder { color : rgba(255, 255, 255, 0.75); } -input[type=button], input[type=submit] { cursor: pointer; } - -.sidebar { position: relative; z-index: 100; margin-top: 0.6rem; padding: 1rem 3rem 2rem 0.4rem; background: #9e7eb4; box-shadow: inset -0.6rem 0 0.8rem -0.5rem rgba(50, 50, 50, 0.5); } -.sidebar a { color: #ffa74d; } - -.sidebar ul { position: relative; margin: 0.3rem 0.3rem 0.3rem 1rem; padding: 0.3rem 0.3rem 0.3rem 1rem; list-style-type: none; } -.sidebar li { position: relative; margin: 0.3rem; padding: 0.3rem; } - -.sidebar ul:before { content: ""; position: absolute; top: 0; left: 0; height: 100%; border-left: 2px dashed rgba(50, 50, 50, 0.4); } -.sidebar li:before { content: ""; position: absolute; width: 1rem; top: 0.8rem; left: -1.2rem; border-bottom: 2px dashed rgba(50, 50, 50, 0.4); } - -.preview { text-align: center; } -.preview:hover img, .preview:hover video, .preview:hover audio { --checkerboard-bg: rgba(200, 200, 200, 0.2); max-width: 100%; background-color: #eee; background-image: linear-gradient(45deg, var(--checkerboard-bg) 25%, transparent 25%, transparent 75%, var(--checkerboard-bg) 75%, var(--checkerboard-bg)), linear-gradient(45deg, var(--checkerboard-bg) 25%, transparent 25%, transparent 75%, var(--checkerboard-bg) 75%, var(--checkerboard-bg)); background-size:2em 2em; background-position:0 0, 1em 1em; } -.image-controls ul { list-style-type: none; margin: 5px; padding: 5px; } -.image-controls li { display: inline-block; margin: 5px; padding: 5px; } -.link-display { margin-left: 0.5rem; } - -audio, video, img { max-width: 100%; } -figure:not(.preview) { display: inline-block; } -figure:not(.preview) > :first-child { display: block; } -figcaption { text-align: center; } - -.printable { padding: 2rem; } - -h1 { text-align: center; } -.sitename { margin-top: 5rem; margin-bottom: 3rem; font-size: 2.5rem; } -.logo { max-width: 4rem; max-height: 4rem; vertical-align: middle; } -.logo.small { max-width: 2rem; max-height: 2rem; } -main:not(.printable) { position: relative; z-index: 1000; padding: 2rem 2rem 0.5rem 2rem; background: #faf8fb; box-shadow: 0 0.1rem 1rem 0.3rem rgba(50, 50, 50, 0.5); } - -blockquote { padding-left: 1em; border-left: 0.2em solid #442772; border-radius: 0.2rem; } - -a { cursor: pointer;; } -a.redlink:link { color: rgb(230, 7, 7); } -a.redlink:visited { color: rgb(130, 15, 15); /*#8b1a1a*/ } - -.search-result { position: relative; } -.search-result::before { content: attr(data-result-number); position: relative; top: 3rem; color: rgba(33, 33, 33, 0.3); font-size: 2rem; } -.search-result::after { content: "Rank: " attr(data-rank); position: absolute; top: 3.8rem; right: 0.7rem; color: rgba(50, 50, 50, 0.3); } -.search-result > h2 { margin-left: 3rem; } -.search-context { max-height: 20em; overflow: hidden; } -.search-context::after { content: ""; position: absolute; bottom: 0; width: 100%; height: 3em; display: block; background: linear-gradient(to bottom, transparent, #faf8fb); pointer-events: none; } - -textarea[name=content] { resize: none; } -.fit-text-mirror { position: absolute; left: -10000vw; word-wrap: break-word; white-space: pre-wrap; } -main label:not(.link-display-label) { display: inline-block; min-width: 16rem; } -input[type=text]:not(.link-display), input[type=password], input[type=url], input[type=email], input[type=number], textarea { margin: 0.5rem 0; } -input[type=text], input[type=password], input[type=url], input[type=email], input[type=number], textarea, textarea[name=content] + pre, #search-box { padding: 0.5rem 0.8rem; background: #d5cbf9; border: 0; border-radius: 0.3rem; font-size: 1rem; color: #442772; } -textarea { min-height: 10em; line-height: 1.3em; font-size: 1.25rem; } -textarea, textarea[name=content] + pre, textarea ~ input[type=submit], #search-box { width: calc(100% - 0.3rem); box-sizing: border-box; } -textarea ~ input[type=submit] { margin: 0.5rem 0; padding: 0.5rem; font-weight: bolder; } -.editform input[type=text] { width: calc(100% - 0.3rem); box-sizing: border-box; } -.jump-to-comments { position: relative; top: -2.5em; display: block; text-align: right; pointer-events: none; } -.jump-to-comments > a { pointer-events: all; } - -.file-gallery { margin: 0.5em; padding: 0.5em; list-style-type: none; } -.file-gallery > li { display: inline-block; min-width: attr(data-gallery-width); padding: 1em; text-align: center; } -.file-gallery > li img, .file-gallery > li video, .file-gallery > li audio { display: block; margin: 0 auto; background-color: white; } - -.page-tags-display { margin: 0.5rem 0 0 0; padding: 0; list-style-type: none; } -.page-tags-display li { display: inline-block; margin: 0.5rem; padding: 0.5rem; background: #e2d5eb; white-space: nowrap; } -.page-tags-display li a { color: #fb701a; text-decoration: none; } -.page-tags-display li::before { content: "\\A"; position: relative; top: 0.03rem; left: -0.9rem; width: 0; height: 0; border-top: 0.6rem solid transparent; border-bottom: 0.6rem solid transparent; border-right: 0.5rem solid #e2d5eb; } - -.page-list { list-style-type: none; margin: 0.3rem; padding: 0.3rem; } -.page-list li:not(.header) { margin: 0.3rem; padding: 0.3rem; } -.page-list li .size { margin-left: 0.7rem; color: rgba(30, 30, 30, 0.5); } -.page-list li .editor { display: inline-block; margin: 0 0.5rem; } -.page-list li .tags { margin: 0 1rem; } -.tag-list { list-style-type: none; margin: 0.5rem; padding: 0.5rem; } -.tag-list li { display: inline-block; margin: 1rem; } -.mini-tag { background: #e2d5eb; padding: 0.2rem 0.4rem; color: #fb701a; text-decoration: none; } - -.help-section-header::after { content: "#" attr(id); float: right; color: rgba(0, 0, 0, 0.4); font-size: 0.8rem; font-weight: normal; } - -.stacked-bar { display: flex; } -.stacked-bar-part { break-inside: avoid; white-space: pre; padding: 0.2em 0.3em; } - -.cursor-query { cursor: help; } - -summary { cursor: pointer; } - -.larger { color: rgb(9, 180, 0); } -.smaller, .deletion { color: rgb(207, 28, 17); } -.nochange { color: rgb(132, 123, 199); font-style: italic; } -.significant { font-weight: bolder; font-size: 1.1rem; } -.deletion, .deletion > .editor { text-decoration: line-through; } - -.highlighted-diff { white-space: pre-wrap; } -.diff-added { background-color: rgba(31, 171, 36, 0.6); color: rgba(23, 125, 27, 1); } -.diff-removed { background-color: rgba(255, 96, 96, 0.6); color: rgba(191, 38, 38, 1); } - -.newpage::before { content: "N"; margin: 0 0.3em 0 -1em; font-weight: bolder; text-decoration: underline dotted; } -.upload::before { content: "\\1f845"; margin: 0 0.1em 0 -1.1em; } -.new-comment::before { content: "\\1f4ac"; margin: 0 0.1em 0 -1.1em; } - -.comments { padding: 1em 2em; background: hsl(31, 64%, 85%); box-shadow: 0 0.1rem 1rem 0.3rem rgba(50, 50, 50, 0.5); } -.comments textarea { background: hsl(270, 60%, 86%); } -.comments ::-webkit-input-placeholder { color: hsla(240, 61%, 67%, 0.61); } -.comments .not-logged-in { padding: 0.3em 0.65em; background: hsla(27, 92%, 68%, 0.64); border-radius: 0.2em; font-style: italic; } - -.comment { margin: 1em 0; padding: 0.01em 0; background: hsla(30, 84%, 72%, 0.54); } -.comment-header { padding: 0 1em; } -.comment .name { font-weight: bold; } -.comment-body { padding: 0 1em; } -.comment-footer { padding-left: 1em; } -.comment-footer-item { padding: 0 0.3em; } -.permalink-button { text-decoration: none; } -.comments-list .comments-list .comment { margin: 1em; } - -.reply-box-container.active { padding: 1em; background: hsla(32, 82%, 62%, 0.3); } - -footer { padding: 2rem; } -/* #ffdb6d #36962c hsl(36, 78%, 80%) hsl(262, 92%, 68%, 0.42) */ -THEMECSS; -if($settings->css === "auto") - $settings->css = $defaultCSS; - - + +/* + * Pepperminty Wiki + * ================ + * Inspired by Minty Wiki by am2064 + * Link: https://github.com/am2064/Minty-Wiki + * + * Credits: + * Code by @Starbeamrainbowlabs + * Parsedown - by erusev and others on github from http://parsedown.org/ + * Mathematical Expression rendering + * Code: @con-f-use + * Rendering: MathJax (https://www.mathjax.org/) + * Bug reports: + * #2 - Incorrect closing tag - nibreh + * #8 - Rogue tag - nibreh + */ +$guiConfig = <<<'GUICONFIG' +{ + "sitename": {"type": "text", "description": "Your wiki's name.", "default": "Pepperminty Wiki"}, + "defaultpage": {"type": "text", "description": "The name of the page that will act as the home page for the wiki. This page will be served if you don't specify a page.", "default": "Main Page"}, + "admindetails_name": {"type": "text", "description": "Your name as the wiki administrator.", "default": "Administrator"}, + "admindetails_email": {"type": "email", "description": "Your email address as the wiki administrator. Will be displayed as a support contact address.", "default": "admin@localhost"}, + "favicon": {"type": "url", "description": "A url that points to the favicon you want to use for your wiki. This image By default this is set to a data: url of a Peppermint (Credit: by bluefrog23, source: https://openclipart.org/detail/19571/peppermint-candy-by-bluefrog23)", "default": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAB3VBMVEXhERHbKCjeVVXjb2/kR0fhKirdHBziDg6qAADaHh7qLy/pdXXUNzfMAADYPj7ZPDzUNzfbHx/fERHpamrqMTHgExPdHx/bLCzhLS3fVFTjT0/ibm7kRkbiLi7aKirdISHeFBTqNDTpeHjgERHYJCTVODjYQkLaPj6/AADVOTnpbW3cIyPdFRXcJCThMjLiTU3ibW3fVVXaKyvcERH4ODj+8fH/////fHz+Fxf4KSn0UFD/CAj/AAD/Xl7/wMD/EhL//v70xMT/+Pj/iYn/HBz/g4P/IyP/Kyv/7Oz0QUH/9PT/+vr/ior/Dg7/vr7/aGj/QED/bGz/AQH/ERH/Jib/R0f/goL/0dH/qan/YWH/7e3/Cwv4R0f/MTH/enr/vLz/u7v/cHD/oKD/n5//aWn+9/f/k5P/0tL/trb/QUH/cXH/dHT/wsL/DQ3/p6f/DAz/1dX/XV3/kpL/i4v/Vlb/2Nj/9/f/pKT+7Oz/V1f/iIj/jIz/r6//Zmb/lZX/j4//T0//Dw/4MzP/GBj/+fn/o6P/TEz/xMT/b2//Tk7/OTn/HR3/hIT/ODj/Y2P/CQn/ZGT/6Oj0UlL/Gxv//f3/Bwf/YmL/6+v0w8P/Cgr/tbX0QkL+9fX4Pz/qNzd0dFHLAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfeCxINNSdmw510AAAA5ElEQVQYGQXBzSuDAQCA8eexKXOwmSZepa1JiPJxsJOrCwcnuchBjg4O/gr7D9zk4uAgJzvuMgcTpYxaUZvSm5mUj7TX7ycAqvoLIJBwStVbP0Hom1Z/ejoxrbaR1Jz6nWinbKWttGRgMSSjanPktRY6mB9WtRNTn7Ilh7LxnNpKq2/x5LnBitfz+hx0qxUaxhZ6vwqq9bx6f2XXvuUl9SVQS38NR7cvln3v15tZ9bQpuWDtZN3Lgh5DWJex3Y+z1KrVhw21+CiM74WZo83DiXq0dVBDYNJkFEU7WrwDAZhRtQrwDzwKQbT6GboLAAAAAElFTkSuQmCC"}, + "logo_url": {"type": "url", "description": "A url that points to the site's logo. Leave blank to disable. When enabled the logo will be inserted next to the site name on every page.", "default": "//starbeamrainbowlabs.com/images/logos/peppermint.png"}, + "logo_position": {"type": "text", "description": "The side of the site name at which the logo should be placed.", "default": "left"}, + "show_subpages": {"type": "text", "description": "Whether to show a list of subpages at the bottom of the page.", "default": true}, + "subpages_display_depth": {"type": "text", "description": "The depth to which we should display when listing subpages at the bottom the page.", "default": 3}, + "footer_message": {"type": "textarea", "description": "A message that will appear at the bottom of every page. May contain HTML.", "default": "All content is under this license. Please make sure that you read and understand the license, especially if you are thinking about copying some (or all) of this site's content, as it may restrict you from doing so."}, + "editing_message": {"type": "textarea", "description": "A message that will appear just before the submit button on the editing page. May contain HTML.", "default": "Formatting help (Markdown Cheatsheet)
\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"}, + "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 sha256 (or sha3 if you have that option turned on)", "default": { + "admin": { + "email": "admin@somewhere.com", + "password": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + }, + "user": { + "email": "example@example.net", + "password": "873ac9ffea4dd04fa719e8920cd6938f0c23cd678af330939cff53c3d2855f34" + } + }}, + "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": "⚙ " }, + "use_sha3": {"type": "checkbox", "description": "Whether to use the new sha3 hashing algorithm for passwords etc.", "default": false }, + "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.
", "default": [ + "user-status", + [ + "Home", + "index.php" + ], + "search", + [ + "Read", + "index.php?page={page}" + ], + [ + "Edit", + "index.php?action=edit&page={page}" + ], + [ + "All Pages", + "index.php?action=list" + ], + "menu" + ]}, + "nav_links_extra": {"type": "nav", "description": "An array of additional links in the above format that will be shown under \"More\" subsection.", "default": [ + [ + "🕓 Page History", + "?action=history&page={page}" + ], + [ + "🎫 All Tags", + "index.php?action=list-tags" + ], + [ + "⇝ Random Page", + "?action=random" + ], + [ + "Recent changes", + "?action=recent-changes" + ], + [ + "🡅 Upload", + "index.php?action=upload" + ], + [ + "⌧ ◆Delete", + "index.php?action=delete&page={page}" + ], + [ + "⎘ ◆Move", + "index.php?action=move&page={page}" + ], + [ + "🔐 ◆Toggle Protection", + "index.php?action=protect&page={page}" + ], + [ + "⚙ ◆Edit master settings", + "index.php?action=configure" + ] + ]}, + "nav_links_bottom": {"type": "nav", "description": "An array of links in the above format that will be shown at the bottom of the page.", "default": [ + [ + "🖶 Printable version", + "index.php?action=view&mode=printable&page={page}" + ], + [ + "Credits", + "index.php?action=credits" + ], + [ + "Help", + "index.php?action=help" + ] + ]}, + "comment_max_length": {"type": "number", "description": "The maximum allowed length, in characters, for comments", "default": 5000 }, + "comment_min_length": {"type": "number", "description": "The minimum allowed length, in characters, for comments", "default": 10 }, + "comment_time_icon": {"type": "text", "description": "The icon to show next to the time that a comment was posted.", "default": "🕗" }, + "upload_enabled": {"type": "checkbox", "description": "Whether to allow uploads to the server.", "default": true}, + "upload_allowed_file_types": {"type": "array", "description": "An array of mime types that are allowed to be uploaded.", "default": [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", + "video/mp4", + "video/webm", + "audio/mp4", + "audio/mpeg" + ]}, + "preview_file_type": {"type": "text", "description": "The default file type for previews.", "default": "image/png"}, + "default_preview_size": {"type": "number", "description": "The default size of preview images in pixels.", "default": 640}, + "mime_extension_mappings_location": {"type": "text", "description": "The location of a file that maps mime types onto file extensions and vice versa. Used to generate the file extension for an uploaded file. See the configuration guide for windows instructions.", "default": "/etc/mime.types"}, + "mime_mappings_overrides": {"type": "map", "description": "Override mappings to convert mime types into the appropriate file extension. Used to override the above file if it assigns weird extensions to any mime types.", "default": { + "text/plain": "txt", + "audio/mpeg": "mp3" + }}, + "min_preview_size": {"type": "number", "description": "The minimum allowed size of generated preview images in pixels.", "default": 1}, + "max_preview_size": {"type": "number", "description": "The maximum allowed size of generated preview images in pixels.", "default": 2048}, + "search_characters_context": {"type": "number", "description": "The number of characters that should be displayed either side of a matching term in the context below each search result.", "default": 200}, + "search_title_matches_weighting": {"type": "number", "description": "The weighting to give to search term matches found in a page's title.", "default": 10}, + "search_tags_matches_weighting": {"type": "number", "description": "The weighting to give to search term matches found in a page's tags.", "default": 3}, + "dynamic_page_suggestion_count": {"type": "number", "description": "The number of dynamic page name suggestions to fetch from the server when typing in the page search box. Note that lowering this number doesn't really improve performance. Set to 0 to disable.", "default": 7 }, + "defaultaction": {"type": "text", "description": "The default action. This action will be performed if no other action is specified. It is recommended you set this to \"view\" - that way the user automatically views the default page (see above).", "default": "view"}, + "updateurl": {"type": "url", "description": "The url from which to fetch updates. Defaults to the master (development) branch. MAKE SURE THAT THIS POINTS TO A *HTTPS* URL, OTHERWISE SOMEONE COULD INJECT A VIRUS INTO YOUR WIKI!", "default": "https://raw.githubusercontent.com/sbrl/pepperminty-wiki/master/index.php"}, + "optimize_pages": {"type": "checkbox", "description": "Whether to optimise all webpages generated.", "default": true}, + "max_recent_changes": {"type": "number", "description": "The maximum number of recent changes to display on the recent changes page.", "default": 512}, + "export_allow_only_admins": {"type": "checkbox", "description": "Whether to only allow adminstrators to export the your wiki as a zip using the page-export module.", "default": false}, + "sessionprefix": {"type": "text", "description": "You shouldn't need to change this. The prefix that should be used in the names of the session variables. Defaults to \"auto\", which automatically generates this field. See the readme for more information.", "default": "auto"}, + "sessionlifetime": { "type": "number", "description": "Again, you shouldn't need to change this under normal circumstances. This setting controls the lifetime of a login session. Defaults to 24 hours, but it may get cut off sooner depending on the underlying PHP session lifetime.", "default": 86400 }, + "css": {"type": "textarea", "description": "A string of css to include. Will be included in the <head> of every page inside a <style> tag. This may also be an absolute url - urls will be referenced via a <link rel='stylesheet' /> tag.", "default": "auto"} +} +GUICONFIG; + +$settingsFilename = "peppermint.json"; + +$guiConfig = json_decode($guiConfig); +$settings = new stdClass(); +if(!file_exists($settingsFilename)) +{ + // Copy the default settings over to the main settings array + foreach ($guiConfig as $key => $value) + $settings->$key = $value->default; + // Generate a random secret + $settings->secret = bin2hex(openssl_random_pseudo_bytes(16)); + file_put_contents("peppermint.json", json_encode($settings, JSON_PRETTY_PRINT)); +} +else + $settings = json_decode(file_get_contents("peppermint.json")); + +if($settings === null) +{ + header("content-type: text/plain"); + exit("Error: Failed to decode the settings file! Does it contain a syntax error?"); +} + +// Fill in any missing properties +$settingsUpgraded = false; +foreach($guiConfig as $key => $propertyData) +{ + if(!isset($settings->$key)) + { + $settings->$key = $propertyData->default; + $settingsUpgraded = true; + } +} +if($settingsUpgraded) + file_put_contents("peppermint.json", json_encode($settings, JSON_PRETTY_PRINT)); + +// Insert the default CSS if requested +$defaultCSS = << span { flex: 1; text-align: center; line-height: 2; display: inline-block; margin: 0; padding: 0.3rem 0.5rem; border-left: 3px solid #442772; border-right: 3px solid #442772; } +nav:not(.nav-more-menu) a { text-decoration: none; font-weight: bolder; color: inherit; } +.nav-divider { color: transparent; } + +.nav-more { position: relative; background-color: #442772; min-width: 10em; } +.nav-more label { cursor: pointer; } +.nav-more-menu { display: none; z-index: 10000; position: absolute; flex-direction: column; top: 2.6rem; right: -0.2rem; background-color: #8a62a7; border-top: 3px solid #442772; border-bottom: 3px solid #442772; } +input[type=checkbox]:checked ~ .nav-more-menu { display: block; box-shadow: 0.4rem 0.4rem 1rem 0 rgba(50, 50, 50, 0.5); } +.nav-more-menu span { min-width: 10rem; } + +.inflexible { flex: none; } +.off-screen { position: absolute; top: -1000px; left: -1000px;} + +input[type=search] { width: 14rem; padding: 0.3rem 0.4rem; font-size: 1rem; color: white; background: rgba(255, 255, 255, 0.4); border: 0; border-radius: 0.3rem; } +input[type=search]::-webkit-input-placeholder { color : rgba(255, 255, 255, 0.75); } +input[type=button], input[type=submit] { cursor: pointer; } + +.sidebar { position: relative; z-index: 100; margin-top: 0.6rem; padding: 1rem 3rem 2rem 0.4rem; background: #9e7eb4; box-shadow: inset -0.6rem 0 0.8rem -0.5rem rgba(50, 50, 50, 0.5); } +.sidebar a { color: #ffa74d; } + +.sidebar ul { position: relative; margin: 0.3rem 0.3rem 0.3rem 1rem; padding: 0.3rem 0.3rem 0.3rem 1rem; list-style-type: none; } +.sidebar li { position: relative; margin: 0.3rem; padding: 0.3rem; } + +.sidebar ul:before { content: ""; position: absolute; top: 0; left: 0; height: 100%; border-left: 2px dashed rgba(50, 50, 50, 0.4); } +.sidebar li:before { content: ""; position: absolute; width: 1rem; top: 0.8rem; left: -1.2rem; border-bottom: 2px dashed rgba(50, 50, 50, 0.4); } + +.preview { text-align: center; } +.preview:hover img, .preview:hover video, .preview:hover audio { --checkerboard-bg: rgba(200, 200, 200, 0.2); max-width: 100%; background-color: #eee; background-image: linear-gradient(45deg, var(--checkerboard-bg) 25%, transparent 25%, transparent 75%, var(--checkerboard-bg) 75%, var(--checkerboard-bg)), linear-gradient(45deg, var(--checkerboard-bg) 25%, transparent 25%, transparent 75%, var(--checkerboard-bg) 75%, var(--checkerboard-bg)); background-size:2em 2em; background-position:0 0, 1em 1em; } +.image-controls ul { list-style-type: none; margin: 5px; padding: 5px; } +.image-controls li { display: inline-block; margin: 5px; padding: 5px; } +.link-display { margin-left: 0.5rem; } + +audio, video, img { max-width: 100%; } +figure:not(.preview) { display: inline-block; } +figure:not(.preview) > :first-child { display: block; } +figcaption { text-align: center; } + +.printable { padding: 2rem; } + +h1 { text-align: center; } +.sitename { margin-top: 5rem; margin-bottom: 3rem; font-size: 2.5rem; } +.logo { max-width: 4rem; max-height: 4rem; vertical-align: middle; } +.logo.small { max-width: 2rem; max-height: 2rem; } +main:not(.printable) { position: relative; z-index: 1000; padding: 2rem 2rem 0.5rem 2rem; background: #faf8fb; box-shadow: 0 0.1rem 1rem 0.3rem rgba(50, 50, 50, 0.5); } + +blockquote { padding-left: 1em; border-left: 0.2em solid #442772; border-radius: 0.2rem; } + +a { cursor: pointer;; } +a.redlink:link { color: rgb(230, 7, 7); } +a.redlink:visited { color: rgb(130, 15, 15); /*#8b1a1a*/ } + +.search-result { position: relative; } +.search-result::before { content: attr(data-result-number); position: relative; top: 3rem; color: rgba(33, 33, 33, 0.3); font-size: 2rem; } +.search-result::after { content: "Rank: " attr(data-rank); position: absolute; top: 3.8rem; right: 0.7rem; color: rgba(50, 50, 50, 0.3); } +.search-result > h2 { margin-left: 3rem; } +.search-context { max-height: 20em; overflow: hidden; } +.search-context::after { content: ""; position: absolute; bottom: 0; width: 100%; height: 3em; display: block; background: linear-gradient(to bottom, transparent, #faf8fb); pointer-events: none; } + +textarea[name=content] { resize: none; } +.fit-text-mirror { position: absolute; left: -10000vw; word-wrap: break-word; white-space: pre-wrap; } +main label:not(.link-display-label) { display: inline-block; min-width: 16rem; } +input[type=text]:not(.link-display), input[type=password], input[type=url], input[type=email], input[type=number], textarea { margin: 0.5rem 0; } +input[type=text], input[type=password], input[type=url], input[type=email], input[type=number], textarea, textarea[name=content] + pre, #search-box { padding: 0.5rem 0.8rem; background: #d5cbf9; border: 0; border-radius: 0.3rem; font-size: 1rem; color: #442772; } +textarea { min-height: 10em; line-height: 1.3em; font-size: 1.25rem; } +textarea, textarea[name=content] + pre, textarea ~ input[type=submit], #search-box { width: calc(100% - 0.3rem); box-sizing: border-box; } +textarea ~ input[type=submit] { margin: 0.5rem 0; padding: 0.5rem; font-weight: bolder; } +.editform input[type=text] { width: calc(100% - 0.3rem); box-sizing: border-box; } +.jump-to-comments { position: relative; top: -2.5em; display: block; text-align: right; pointer-events: none; } +.jump-to-comments > a { pointer-events: all; } + +.file-gallery { margin: 0.5em; padding: 0.5em; list-style-type: none; } +.file-gallery > li { display: inline-block; min-width: attr(data-gallery-width); padding: 1em; text-align: center; } +.file-gallery > li img, .file-gallery > li video, .file-gallery > li audio { display: block; margin: 0 auto; background-color: white; } + +.page-tags-display { margin: 0.5rem 0 0 0; padding: 0; list-style-type: none; } +.page-tags-display li { display: inline-block; margin: 0.5rem; padding: 0.5rem; background: #e2d5eb; white-space: nowrap; } +.page-tags-display li a { color: #fb701a; text-decoration: none; } +.page-tags-display li::before { content: "\\A"; position: relative; top: 0.03rem; left: -0.9rem; width: 0; height: 0; border-top: 0.6rem solid transparent; border-bottom: 0.6rem solid transparent; border-right: 0.5rem solid #e2d5eb; } + +.page-list { list-style-type: none; margin: 0.3rem; padding: 0.3rem; } +.page-list li:not(.header) { margin: 0.3rem; padding: 0.3rem; } +.page-list li .size { margin-left: 0.7rem; color: rgba(30, 30, 30, 0.5); } +.page-list li .editor { display: inline-block; margin: 0 0.5rem; } +.page-list li .tags { margin: 0 1rem; } +.tag-list { list-style-type: none; margin: 0.5rem; padding: 0.5rem; } +.tag-list li { display: inline-block; margin: 1rem; } +.mini-tag { background: #e2d5eb; padding: 0.2rem 0.4rem; color: #fb701a; text-decoration: none; } + +.help-section-header::after { content: "#" attr(id); float: right; color: rgba(0, 0, 0, 0.4); font-size: 0.8rem; font-weight: normal; } + +.stacked-bar { display: flex; } +.stacked-bar-part { break-inside: avoid; white-space: pre; padding: 0.2em 0.3em; } + +.cursor-query { cursor: help; } + +summary { cursor: pointer; } + +.larger { color: rgb(9, 180, 0); } +.smaller, .deletion { color: rgb(207, 28, 17); } +.nochange { color: rgb(132, 123, 199); font-style: italic; } +.significant { font-weight: bolder; font-size: 1.1rem; } +.deletion, .deletion > .editor { text-decoration: line-through; } + +.highlighted-diff { white-space: pre-wrap; } +.diff-added { background-color: rgba(31, 171, 36, 0.6); color: rgba(23, 125, 27, 1); } +.diff-removed { background-color: rgba(255, 96, 96, 0.6); color: rgba(191, 38, 38, 1); } + +.newpage::before { content: "N"; margin: 0 0.3em 0 -1em; font-weight: bolder; text-decoration: underline dotted; } +.upload::before { content: "\\1f845"; margin: 0 0.1em 0 -1.1em; } +.new-comment::before { content: "\\1f4ac"; margin: 0 0.1em 0 -1.1em; } + +.comments { padding: 1em 2em; background: hsl(31, 64%, 85%); box-shadow: 0 0.1rem 1rem 0.3rem rgba(50, 50, 50, 0.5); } +.comments textarea { background: hsl(270, 60%, 86%); } +.comments ::-webkit-input-placeholder { color: hsla(240, 61%, 67%, 0.61); } +.comments .not-logged-in { padding: 0.3em 0.65em; background: hsla(27, 92%, 68%, 0.64); border-radius: 0.2em; font-style: italic; } + +.comment { margin: 1em 0; padding: 0.01em 0; background: hsla(30, 84%, 72%, 0.54); } +.comment-header { padding: 0 1em; } +.comment .name { font-weight: bold; } +.comment-body { padding: 0 1em; } +.comment-footer { padding-left: 1em; } +.comment-footer-item { padding: 0 0.3em; } +.permalink-button { text-decoration: none; } +.comments-list .comments-list .comment { margin: 1em; } + +.reply-box-container.active { padding: 1em; background: hsla(32, 82%, 62%, 0.3); } + +footer { padding: 2rem; } +/* #ffdb6d #36962c hsl(36, 78%, 80%) hsl(262, 92%, 68%, 0.42) */ +THEMECSS; +if($settings->css === "auto") + $settings->css = $defaultCSS; + + /////////////////////////////////////////////////////////////////////////////////////////////// @@ -1830,141 +1830,141 @@ register_module([ ]); - - -register_module([ - "name" => "Page protection", - "version" => "0.2", - "author" => "Starbeamrainbowlabs", - "description" => "Exposes Pepperminty Wiki's new page protection mechanism and makes the protect button in the 'More...' menu on the top bar work.", - "id" => "action-protect", - "code" => function() { - /** - * @api {get} ?action=protect&page={pageName} Toggle the protection of a page. - * @apiName Protect - * @apiGroup Page - * @apiPermission Moderator - * - * @apiParam {string} page The page name to toggle the protection of. - */ - - /* - * ██████ ██████ ██████ ████████ ███████ ██████ ████████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██████ ██ ██ ██ █████ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██████ ██ ███████ ██████ ██ - */ - add_action("protect", function() { - global $env, $pageindex, $paths, $settings; - - // Make sure that the user is logged in as an admin / mod. - if($env->is_admin) - { - // They check out ok, toggle the page's protection. - $page = $env->page; - - if(!isset($pageindex->$page->protect)) - { - $pageindex->$page->protect = true; - } - else if($pageindex->$page->protect === true) - { - $pageindex->$page->protect = false; - } - else if($pageindex->$page->protect === false) - { - $pageindex->$page->protect = true; - } - - // Save the pageindex - file_put_contents($paths->pageindex, json_encode($pageindex, JSON_PRETTY_PRINT)); - - $state = ($pageindex->$page->protect ? "enabled" : "disabled"); - $title = "Page protection $state."; - exit(page_renderer::render_main($title, "

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.1", - "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 Redirects to a random page. - * @apiName RawSource - * @apiGroup Page - * @apiPermission Anonymous - */ - - add_action("random", function() { - global $pageindex; - - $pageNames = array_keys(get_object_vars($pageindex)); - $randomPageName = $pageNames[array_rand($pageNames)]; - - http_response_code(307); - header("location: ?page=" . rawurlencode($randomPageName)); - }); - - 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 $env; - - header("content-type: text/markdown"); - 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.

"); - } -]); - - - + + +register_module([ + "name" => "Page protection", + "version" => "0.2", + "author" => "Starbeamrainbowlabs", + "description" => "Exposes Pepperminty Wiki's new page protection mechanism and makes the protect button in the 'More...' menu on the top bar work.", + "id" => "action-protect", + "code" => function() { + /** + * @api {get} ?action=protect&page={pageName} Toggle the protection of a page. + * @apiName Protect + * @apiGroup Page + * @apiPermission Moderator + * + * @apiParam {string} page The page name to toggle the protection of. + */ + + /* + * ██████ ██████ ██████ ████████ ███████ ██████ ████████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██████ ██ ██ ██ █████ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██████ ██ ███████ ██████ ██ + */ + add_action("protect", function() { + global $env, $pageindex, $paths, $settings; + + // Make sure that the user is logged in as an admin / mod. + if($env->is_admin) + { + // They check out ok, toggle the page's protection. + $page = $env->page; + + if(!isset($pageindex->$page->protect)) + { + $pageindex->$page->protect = true; + } + else if($pageindex->$page->protect === true) + { + $pageindex->$page->protect = false; + } + else if($pageindex->$page->protect === false) + { + $pageindex->$page->protect = true; + } + + // Save the pageindex + file_put_contents($paths->pageindex, json_encode($pageindex, JSON_PRETTY_PRINT)); + + $state = ($pageindex->$page->protect ? "enabled" : "disabled"); + $title = "Page protection $state."; + exit(page_renderer::render_main($title, "

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.1", + "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 Redirects to a random page. + * @apiName RawSource + * @apiGroup Page + * @apiPermission Anonymous + */ + + add_action("random", function() { + global $pageindex; + + $pageNames = array_keys(get_object_vars($pageindex)); + $randomPageName = $pageNames[array_rand($pageNames)]; + + http_response_code(307); + header("location: ?page=" . rawurlencode($randomPageName)); + }); + + 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 $env; + + header("content-type: text/markdown"); + 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.

"); + } +]); + + + register_module([ "name" => "Sidebar", @@ -2068,6 +2068,9 @@ function render_sidebar($pageindex, $root_pagename = "") if($pagename == $root_pagename) continue; + // If the page already appears on the sidebar, skip it + if(preg_match("/>$pagename<\a>/m",$result)===1) + continue; // If the part of the current pagename that comes after the root // pagename has a slash in it, skip it as it is a sub-sub page. @@ -2084,508 +2087,508 @@ function render_sidebar($pageindex, $root_pagename = "") } - - -register_module([ - "name" => "Page Comments", - "version" => "0.2", - "author" => "Starbeamrainbowlabs", - "description" => "Adds threaded comments to the bottom of every page.", - "id" => "feature-comments", - "code" => function() { - global $env; - - /** - * @api {post} ?action=comment Comment on a page - * @apiName Comment - * @apiGroup Comment - * @apiPermission User - * @apiDescription Posts a comment on a page, optionally in reply to another comment. Currently, comments must be made by a logged-in user. - * - * @apiParam {string} message The comment text. Supports the same syntax that the renderer of the main page supports. The default is extended markdown - see the help page of the specific wiki for more information. - * @apiParam {string} replyto Optional. If specified the comment will be posted in reply to the comment with the specified id. - * - * - * @apiError CommentNotFound The comment to reply to was not found. - */ - - /* - * ██████ ██████ ███ ███ ███ ███ ███████ ███ ██ ████████ - * ██ ██ ██ ████ ████ ████ ████ ██ ████ ██ ██ - * ██ ██ ██ ██ ████ ██ ██ ████ ██ █████ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██████ ██ ██ ██ ██ ███████ ██ ████ ██ - */ - add_action("comment", function() { - global $settings, $env; - - $reply_to = $_POST["replyto"] ?? null; - $message = $_POST["message"] ?? ""; - - if(!$env->is_logged_in) { - http_response_code(401); - exit(page_renderer::render_main("Error posting comment - $settings->sitename", "

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.

")); - }); - - if($env->action == "view") { - page_renderer::register_part_preprocessor(function(&$parts) { - global $env; - $comments_filename = get_comment_filename($env->page); - $comments_data = file_exists($comments_filename) ? json_decode(file_get_contents($comments_filename)) : []; - - - $comments_html = "\n"; - - $to_comments_link = ""; - - $parts["{extra}"] = $comments_html . $parts["{extra}"]; - - $parts["{content}"] = str_replace_once("", "\n$to_comments_link", $parts["{content}"]); - }); - - $reply_js_snippet = <<<'REPLYJS' -/////////////////////////////////// -///////// Commenting Form ///////// -/////////////////////////////////// -window.addEventListener("load", function(event) { - var replyButtons = document.querySelectorAll(".reply-button"); - for(let i = 0; i < replyButtons.length; i++) { - replyButtons[i].addEventListener("click", display_reply_form); - replyButtons[i].addEventListener("touchend", display_reply_form); - } -}); - -function display_reply_form(event) -{ - // Deep-clone the comment form - var replyForm = document.querySelector(".comment-reply-form").cloneNode(true); - replyForm.classList.add("nested"); - // Set the comment we're replying to - replyForm.querySelector("[name=replyto]").value = event.target.parentElement.parentElement.parentElement.dataset.commentId; - // Display the newly-cloned commenting form - var replyBoxContiner = event.target.parentElement.parentElement.parentElement.querySelector(".reply-box-container"); - replyBoxContiner.classList.add("active"); - replyBoxContiner.appendChild(replyForm); - // Hide the reply button so it can't be pressed more than once - that could - // be awkward :P - event.target.parentElement.removeChild(event.target); -} - -REPLYJS; - page_renderer::AddJSSnippet($reply_js_snippet); - - } - } -]); - -/** - * Given a page name, returns the absolute file path in which that page's - * comments are stored. - * @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. - * @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. - * @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; -} - -/** - * 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. - * @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. - * @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; - - if(count($comments_data) == 0) { - if($depth == 0) - return "

No comments here! Start the conversation above.

"; - else - return ""; - } - - $result = "
"; - - //$comments_data = array_reverse($comments_data); - for($i = count($comments_data) - 1; $i >= 0; $i--) { - $comment = $comments_data[$i]; - - $result .= "\t
\n"; - $result .= "\t

$comment->username said:

"; - $result .= "\t
\n"; - $result .= "\t\t" . parse_page_source($comment->message); - $result .= "\t
\n"; - $result .= "\t
\n"; - $result .= "\t\n"; - $result .= "\t" . render_comments($comment->replies, $depth + 1) . "\n"; - $result .= "\t
"; - } - $result .= "
"; - - return $result; -} - - - - -register_module([ - "name" => "Settings GUI", - "version" => "0.1.1", - "author" => "Starbeamrainbowlabs", - "description" => "The module everyone has been waiting for! Adds a web based gui that lets mods change the wiki settings.", - "id" => "feature-guiconfig", - "code" => function() { - global $settings; - /** - * @api {get} ?action=configure Get a page to change the global wiki settings - * @apiName ConfigureSettings - * @apiGroup Utility - * @apiPermission Moderator - */ - - /* - * ██████ ██████ ███ ██ ███████ ██ ██████ ██ ██ ██████ ███████ - * ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ █████ ██ ██ ███ ██ ██ ██████ █████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██████ ██ ████ ██ ██ ██████ ██████ ██ ██ ███████ - */ - add_action("configure", function() { - global $settings, $env, $guiConfig; - - if(!$env->is_admin) - { - $errorMessage = "

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 = "

Master Control Panel

\n"; - $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 .= "

Mouse over the name of each setting to see a description of what it does.

\n"; - $content .= "
\n"; - - foreach($guiConfig as $configKey => $configData) - { - // Don't display the site secret~! - // Apparently it got lost in translation, but I'll be re-adding - // it again at some point I'm sure - so support for it is - // included here. - if($configKey == "sitesecret") continue; - - $reverse = false; - $inputControl = ""; - $label = ""; - switch($configData->type) - { - case "url": - case "email": - case "number": - case "text": - $inputControl = ""; - break; - case "textarea": - $inputControl = ""; - break; - case "checkbox": - $reverse = true; - $inputControl = "$configKey ? " checked" : "") . " />"; - break; - default: - $label = ""; - $inputControl = "

Sorry! The $configKey setting isn't editable yet through the gui. Please try editing peppermint.json for the time being.

"; - break; - } - - $content .= "
\n\t"; - $content .= $reverse ? "$inputControl\n\t$label" : "$label\n\t$inputControl"; - $content .= "\n
\n"; - } - - $content .= ""; - $content .= "
\n"; - - exit(page_renderer::render_main("Master Control Panel - $settings->sitename", $content)); - }); - - /** - * @api {post} ?action=configure-save Save changes to the global wiki settings - * @apiName ConfigureSettings - * @apiGroup Utility - * @apiPermission Moderator - */ - - /* - * ██████ ██████ ███ ██ ███████ ██ ██████ ██ ██ ██████ ███████ - * ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ █████ ██ ██ ███ ██ ██ ██████ █████ █████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██████ ██ ████ ██ ██ ██████ ██████ ██ ██ ███████ - * ███████ █████ ██ ██ ███████ - * ██ ██ ██ ██ ██ ██ - * ███████ ███████ ██ ██ █████ - * ██ ██ ██ ██ ██ ██ - * ███████ ██ ██ ████ ███████ - */ - - - add_action("configure-save", function () { - global $env, $settings, $paths, $defaultCSS; - - // If the user isn't an admin, then the regular configuration page will display an appropriate error - if(!$env->is_admin) - { - http_response_code(307); - header("location: ?action=configure"); - exit(); - } - - // Build a new settings object - $newSettings = new stdClass(); - foreach($settings as $configKey => $rawValue) - { - $configValue = $rawValue; - if(isset($_POST[$configKey])) - { - $decodedConfigValue = json_decode($_POST[$configKey]); - if(json_last_error() === JSON_ERROR_NONE) - $configValue = $decodedConfigValue; - else - $configValue = $_POST[$configKey]; - - // Convert boolean settings to a boolean, since POST - // parameters don't decode correctly. - if(is_bool($settings->$configKey)) - $configValue = in_array($configValue, [ 1, "on"], true) ? true : false; - - // If the CSS hasn't changed, then we can replace it with - // 'auto' - this will ensure that upon update the new - // default CSS will be used. Also make sure we ignore line - // ending nonsense & differences here, since they really - // don't matter - if($configKey === "css" && str_replace("\r\n", "\n", $defaultCSS) === str_replace("\r\n", "\n", $configValue)) - $configValue = "auto"; - } - - $newSettings->$configKey = $configValue; - } - - // Take a backup of the current settings file - rename($paths->settings_file, "$paths->settings_file.bak"); - // Save the new settings file - file_put_contents($paths->settings_file, json_encode($newSettings, JSON_PRETTY_PRINT)); - - $content = "

Master settings updated sucessfully

\n"; - $content .= "

$settings->sitename's master settings file has been updated successfully. A backup of the original settings has been created under the name peppermint.json.bak, just in case. You can go back and continue editing the master settings file, or you can go to the " . htmlentities($settings->defaultpage) . ".

\n"; - $content .= "

For reference, the newly generated master settings file is as follows:

\n"; - $content .= "\n"; - exit(page_renderer::render_main("Master Settings Updated - $settings->sitename", $content)); - }); - - 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.

"); - } -]); - - - + + +register_module([ + "name" => "Page Comments", + "version" => "0.2", + "author" => "Starbeamrainbowlabs", + "description" => "Adds threaded comments to the bottom of every page.", + "id" => "feature-comments", + "code" => function() { + global $env; + + /** + * @api {post} ?action=comment Comment on a page + * @apiName Comment + * @apiGroup Comment + * @apiPermission User + * @apiDescription Posts a comment on a page, optionally in reply to another comment. Currently, comments must be made by a logged-in user. + * + * @apiParam {string} message The comment text. Supports the same syntax that the renderer of the main page supports. The default is extended markdown - see the help page of the specific wiki for more information. + * @apiParam {string} replyto Optional. If specified the comment will be posted in reply to the comment with the specified id. + * + * + * @apiError CommentNotFound The comment to reply to was not found. + */ + + /* + * ██████ ██████ ███ ███ ███ ███ ███████ ███ ██ ████████ + * ██ ██ ██ ████ ████ ████ ████ ██ ████ ██ ██ + * ██ ██ ██ ██ ████ ██ ██ ████ ██ █████ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██████ ██ ██ ██ ██ ███████ ██ ████ ██ + */ + add_action("comment", function() { + global $settings, $env; + + $reply_to = $_POST["replyto"] ?? null; + $message = $_POST["message"] ?? ""; + + if(!$env->is_logged_in) { + http_response_code(401); + exit(page_renderer::render_main("Error posting comment - $settings->sitename", "

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.

")); + }); + + if($env->action == "view") { + page_renderer::register_part_preprocessor(function(&$parts) { + global $env; + $comments_filename = get_comment_filename($env->page); + $comments_data = file_exists($comments_filename) ? json_decode(file_get_contents($comments_filename)) : []; + + + $comments_html = "\n"; + + $to_comments_link = ""; + + $parts["{extra}"] = $comments_html . $parts["{extra}"]; + + $parts["{content}"] = str_replace_once("", "\n$to_comments_link", $parts["{content}"]); + }); + + $reply_js_snippet = <<<'REPLYJS' +/////////////////////////////////// +///////// Commenting Form ///////// +/////////////////////////////////// +window.addEventListener("load", function(event) { + var replyButtons = document.querySelectorAll(".reply-button"); + for(let i = 0; i < replyButtons.length; i++) { + replyButtons[i].addEventListener("click", display_reply_form); + replyButtons[i].addEventListener("touchend", display_reply_form); + } +}); + +function display_reply_form(event) +{ + // Deep-clone the comment form + var replyForm = document.querySelector(".comment-reply-form").cloneNode(true); + replyForm.classList.add("nested"); + // Set the comment we're replying to + replyForm.querySelector("[name=replyto]").value = event.target.parentElement.parentElement.parentElement.dataset.commentId; + // Display the newly-cloned commenting form + var replyBoxContiner = event.target.parentElement.parentElement.parentElement.querySelector(".reply-box-container"); + replyBoxContiner.classList.add("active"); + replyBoxContiner.appendChild(replyForm); + // Hide the reply button so it can't be pressed more than once - that could + // be awkward :P + event.target.parentElement.removeChild(event.target); +} + +REPLYJS; + page_renderer::AddJSSnippet($reply_js_snippet); + + } + } +]); + +/** + * Given a page name, returns the absolute file path in which that page's + * comments are stored. + * @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. + * @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. + * @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; +} + +/** + * 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. + * @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. + * @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; + + if(count($comments_data) == 0) { + if($depth == 0) + return "

No comments here! Start the conversation above.

"; + else + return ""; + } + + $result = "
"; + + //$comments_data = array_reverse($comments_data); + for($i = count($comments_data) - 1; $i >= 0; $i--) { + $comment = $comments_data[$i]; + + $result .= "\t
\n"; + $result .= "\t

$comment->username said:

"; + $result .= "\t
\n"; + $result .= "\t\t" . parse_page_source($comment->message); + $result .= "\t
\n"; + $result .= "\t
\n"; + $result .= "\t\n"; + $result .= "\t" . render_comments($comment->replies, $depth + 1) . "\n"; + $result .= "\t
"; + } + $result .= "
"; + + return $result; +} + + + + +register_module([ + "name" => "Settings GUI", + "version" => "0.1.1", + "author" => "Starbeamrainbowlabs", + "description" => "The module everyone has been waiting for! Adds a web based gui that lets mods change the wiki settings.", + "id" => "feature-guiconfig", + "code" => function() { + global $settings; + /** + * @api {get} ?action=configure Get a page to change the global wiki settings + * @apiName ConfigureSettings + * @apiGroup Utility + * @apiPermission Moderator + */ + + /* + * ██████ ██████ ███ ██ ███████ ██ ██████ ██ ██ ██████ ███████ + * ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ █████ ██ ██ ███ ██ ██ ██████ █████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██████ ██ ████ ██ ██ ██████ ██████ ██ ██ ███████ + */ + add_action("configure", function() { + global $settings, $env, $guiConfig; + + if(!$env->is_admin) + { + $errorMessage = "

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 = "

Master Control Panel

\n"; + $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 .= "

Mouse over the name of each setting to see a description of what it does.

\n"; + $content .= "
\n"; + + foreach($guiConfig as $configKey => $configData) + { + // Don't display the site secret~! + // Apparently it got lost in translation, but I'll be re-adding + // it again at some point I'm sure - so support for it is + // included here. + if($configKey == "sitesecret") continue; + + $reverse = false; + $inputControl = ""; + $label = ""; + switch($configData->type) + { + case "url": + case "email": + case "number": + case "text": + $inputControl = ""; + break; + case "textarea": + $inputControl = ""; + break; + case "checkbox": + $reverse = true; + $inputControl = "$configKey ? " checked" : "") . " />"; + break; + default: + $label = ""; + $inputControl = "

Sorry! The $configKey setting isn't editable yet through the gui. Please try editing peppermint.json for the time being.

"; + break; + } + + $content .= "
\n\t"; + $content .= $reverse ? "$inputControl\n\t$label" : "$label\n\t$inputControl"; + $content .= "\n
\n"; + } + + $content .= ""; + $content .= "
\n"; + + exit(page_renderer::render_main("Master Control Panel - $settings->sitename", $content)); + }); + + /** + * @api {post} ?action=configure-save Save changes to the global wiki settings + * @apiName ConfigureSettings + * @apiGroup Utility + * @apiPermission Moderator + */ + + /* + * ██████ ██████ ███ ██ ███████ ██ ██████ ██ ██ ██████ ███████ + * ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ █████ ██ ██ ███ ██ ██ ██████ █████ █████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██████ ██ ████ ██ ██ ██████ ██████ ██ ██ ███████ + * ███████ █████ ██ ██ ███████ + * ██ ██ ██ ██ ██ ██ + * ███████ ███████ ██ ██ █████ + * ██ ██ ██ ██ ██ ██ + * ███████ ██ ██ ████ ███████ + */ + + + add_action("configure-save", function () { + global $env, $settings, $paths, $defaultCSS; + + // If the user isn't an admin, then the regular configuration page will display an appropriate error + if(!$env->is_admin) + { + http_response_code(307); + header("location: ?action=configure"); + exit(); + } + + // Build a new settings object + $newSettings = new stdClass(); + foreach($settings as $configKey => $rawValue) + { + $configValue = $rawValue; + if(isset($_POST[$configKey])) + { + $decodedConfigValue = json_decode($_POST[$configKey]); + if(json_last_error() === JSON_ERROR_NONE) + $configValue = $decodedConfigValue; + else + $configValue = $_POST[$configKey]; + + // Convert boolean settings to a boolean, since POST + // parameters don't decode correctly. + if(is_bool($settings->$configKey)) + $configValue = in_array($configValue, [ 1, "on"], true) ? true : false; + + // If the CSS hasn't changed, then we can replace it with + // 'auto' - this will ensure that upon update the new + // default CSS will be used. Also make sure we ignore line + // ending nonsense & differences here, since they really + // don't matter + if($configKey === "css" && str_replace("\r\n", "\n", $defaultCSS) === str_replace("\r\n", "\n", $configValue)) + $configValue = "auto"; + } + + $newSettings->$configKey = $configValue; + } + + // Take a backup of the current settings file + rename($paths->settings_file, "$paths->settings_file.bak"); + // Save the new settings file + file_put_contents($paths->settings_file, json_encode($newSettings, JSON_PRETTY_PRINT)); + + $content = "

Master settings updated sucessfully

\n"; + $content .= "

$settings->sitename's master settings file has been updated successfully. A backup of the original settings has been created under the name peppermint.json.bak, just in case. You can go back and continue editing the master settings file, or you can go to the " . htmlentities($settings->defaultpage) . ".

\n"; + $content .= "

For reference, the newly generated master settings file is as follows:

\n"; + $content .= "\n"; + exit(page_renderer::render_main("Master Settings Updated - $settings->sitename", $content)); + }); + + 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.

"); + } +]); + + + register_module([ "name" => "Page History", @@ -2683,1844 +2686,1844 @@ function history_add_revision(&$pageinfo, &$newsource, &$oldsource, $save_pagein } - - -register_module([ - "name" => "Recent Changes", - "version" => "0.3.3", - "author" => "Starbeamrainbowlabs", - "description" => "Adds recent changes. Access through the 'recent-changes' action.", - "id" => "feature-recent-changes", - "code" => function() { - global $settings, $env, $paths; - /** - * @api {get} ?action=recentchanges Get a list of recent changes - * @apiName RecentChanges - * @apiGroup Stats - * @apiPermission Anonymous - */ - - // Add the recent changes json file to $paths for convenience. - $paths->recentchanges = $env->storage_prefix . "recent-changes.json"; - // Create the recent changes json file if it doesn't exist - if(!file_exists($paths->recentchanges)) - file_put_contents($paths->recentchanges, "[]"); - - /* - * ██████ ███████ ██████ ███████ ███ ██ ████████ - * ██ ██ ██ ██ ██ ████ ██ ██ - * ██████ █████ ██ █████ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ███████ ██████ ███████ ██ ████ ██ - * - * ██████ ██ ██ █████ ███ ██ ██████ ███████ ███████ - * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ - * ██ ███████ ███████ ██ ██ ██ ██ ███ █████ ███████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██ ██ ██ ██ ██ ████ ██████ ███████ ███████ - */ - add_action("recent-changes", function() { - global $settings, $paths, $pageindex; - - $content = "\t\t

Recent Changes

\n"; - - $recent_changes = json_decode(file_get_contents($paths->recentchanges)); - - if(count($recent_changes) > 0) - { - $content .= render_recent_changes($recent_changes); - } - else - { - // No changes yet :( - $content .= "

None yet! Try making a few changes and then check back here.

\n"; - } - - exit(page_renderer::render("Recent Changes - $settings->sitename", $content)); - }); - - register_save_preprocessor(function(&$pageinfo, &$newsource, &$oldsource) { - global $env, $settings, $paths; - - // Work out the old and new page lengths - $oldsize = strlen($oldsource); - $newsize = strlen($newsource); - // Calculate the page length difference - $size_diff = $newsize - $oldsize; - - $newchange = [ - "type" => "edit", - "timestamp" => time(), - "page" => $env->page, - "user" => $env->user, - "newsize" => $newsize, - "sizediff" => $size_diff - ]; - if($oldsize == 0) - $newchange["newpage"] = true; - - add_recent_change($newchange); - }); - - add_help_section("800-raw-page-content", "Recent Changes", "

The recent changes page displays a list of all the most recent changes that have happened around $settings->sitename, arranged in chronological order. It can be found in the \"More...\" menu in the top right by default.

-

Each entry displays the name of the page in question, who edited it, how long ago they did so, and the number of characters added or removed. Pages that currently redirect to another page are shown in italics, and hovering over the time since the edit wil show the exact time that the edit was made.

"); - } -]); - -/** - * Adds a new recent change to the recent changes file. - * @param array $rchange The new change to add. - */ -function add_recent_change($rchange) -{ - global $settings, $paths; - - $recentchanges = json_decode(file_get_contents($paths->recentchanges), true); - array_unshift($recentchanges, $rchange); - - // Limit the number of entries in the recent changes file if we've - // been asked to. - if(isset($settings->max_recent_changes)) - $recentchanges = array_slice($recentchanges, 0, $settings->max_recent_changes); - - // Save the recent changes file back to disk - file_put_contents($paths->recentchanges, json_encode($recentchanges, JSON_PRETTY_PRINT)); -} - -function render_recent_changes($recent_changes) -{ - global $pageindex; - - // Cache the number of recent changes we are dealing with - $rchange_count = count($recent_changes); - - // Group changes made on the same page and the same day together - for($i = 0; $i < $rchange_count; $i++) - { - for($s = $i + 1; $s < $rchange_count; $s++) - { - // Break out if we have reached the end of the day we are scanning - if(date("dmY", $recent_changes[$i]->timestamp) !== date("dmY", $recent_changes[$s]->timestamp)) - break; - - // If we have found a change that has been made on the same page and - // on the same day as the one that we are scanning for, move it up - // next to the change we are scanning for. - if($recent_changes[$i]->page == $recent_changes[$s]->page && - date("j", $recent_changes[$i]->timestamp) === date("j", $recent_changes[$s]->timestamp)) - { - // FUTURE: We may need to remove and insert instead of swapping changes around if this causes some changes to appear out of order. - $temp = $recent_changes[$i + 1]; - $recent_changes[$i + 1] = $recent_changes[$s]; - $recent_changes[$s] = $temp; - $i++; - } - } - } - - $content = "
    \n"; - $last_time = 0; - for($i = 0; $i < $rchange_count; $i++) - { - $rchange = $recent_changes[$i]; - - if($last_time !== date("dmY", $rchange->timestamp)) - $content .= "
  • " . date("jS F", $rchange->timestamp) . "

  • \n"; - - $rchange_results = []; - for($s = $i; $s < $rchange_count; $s++) - { - if($recent_changes[$s]->page !== $rchange->page) - break; - - $rchange_results[$s] = render_recent_change($recent_changes[$s]); - $i++; - } - // Take one from i to account for when we tick over to the next - // iteration of the main loop - $i -= 1; - - $next_entry = implode("\n", $rchange_results); - // If the change count is greater than 1, then we should enclose it - // in a
    tag. - if(count($rchange_results) > 1) - { - reset($rchange_results); - $rchange_first = $recent_changes[key($rchange_results)]; - end($rchange_results); - $rchange_last = $recent_changes[key($rchange_results)]; - - $pageDisplayHtml = render_pagename($rchange_first); - $timeDisplayHtml = render_timestamp($rchange_first->timestamp); - $users = []; - foreach($rchange_results as $key => $rchange_result) - { - if(!in_array($recent_changes[$key]->user, $users)) - $users[] = $recent_changes[$key]->user; - } - foreach($users as &$user) - $user = page_renderer::render_username($user); - $userDisplayHtml = render_editor(implode(", ", $users)); - - $next_entry = "
  • $pageDisplayHtml $userDisplayHtml $timeDisplayHtml
      $next_entry
  • "; - - $content .= "$next_entry\n"; - } - else - { - $content .= implode("\n", $rchange_results); - } - - $last_time = date("dmY", $rchange->timestamp); - } - $content .= "\t\t
"; - - return $content; -} - -function render_recent_change($rchange) -{ - global $pageindex; - $pageDisplayHtml = render_pagename($rchange); - $editorDisplayHtml = render_editor(page_renderer::render_username($rchange->user)); - $timeDisplayHtml = render_timestamp($rchange->timestamp); - - $revisionId = false; - if(isset($pageindex->{$rchange->page}) && isset($pageindex->{$rchange->page}->history)) - { - foreach($pageindex->{$rchange->page}->history as $historyEntry) - { - if($historyEntry->timestamp == $rchange->timestamp) - { - $revisionId = $historyEntry->rid; - break; - } - } - } - - $result = ""; - $resultClasses = []; - switch(isset($rchange->type) ? $rchange->type : "edit") - { - case "edit": - // The number (and the sign) of the size difference to display - $size_display = ($rchange->sizediff > 0 ? "+" : "") . $rchange->sizediff; - $size_display_class = $rchange->sizediff > 0 ? "larger" : ($rchange->sizediff < 0 ? "smaller" : "nochange"); - if($rchange->sizediff > 500 or $rchange->sizediff < -500) - $size_display_class .= " significant"; - - - $size_title_display = human_filesize($rchange->newsize - $rchange->sizediff) . " -> " . human_filesize($rchange->newsize); - - if(!empty($rchange->newpage)) - $resultClasses[] = "newpage"; - - $result .= "$pageDisplayHtml $editorDisplayHtml $timeDisplayHtml ($size_display)"; - break; - - case "deletion": - $resultClasses[] = "deletion"; - $result .= "$pageDisplayHtml $editorDisplayHtml $timeDisplayHtml"; - break; - - case "upload": - $resultClasses[] = "upload"; - $result .= "$pageDisplayHtml $editorDisplayHtml $timeDisplayHtml (" . human_filesize($rchange->filesize) . ")"; - break; - case "comment": - $resultClasses[] = "new-comment"; - $result .= "$pageDisplayHtml $editorDisplayHtml"; - } - - $resultAttributes = " " . (count($resultClasses) > 0 ? "class='" . implode(" ", $resultClasses) . "'" : ""); - $result = "\t\t\t$result\n"; - - return $result; -} - - - - -register_module([ - "name" => "Redirect pages", - "version" => "0.3", - "author" => "Starbeamrainbowlabs", - "description" => "Adds support for redirect pages. Uses the same syntax that Mediawiki does.", - "id" => "feature-redirect", - "code" => function() { - global $settings; - - register_save_preprocessor(function(&$index_entry, &$pagedata) { - $matches = []; - if(preg_match("/^# ?REDIRECT ?\[\[([^\]]+)\]\]/i", $pagedata, $matches) === 1) - { - //error_log("matches: " . var_export($matches, true)); - // We have found a redirect page! - // Update the metadata to reflect this. - $index_entry->redirect = true; - $index_entry->redirect_target = $matches[1]; - } - else - { - // This page isn't a redirect. Unset the metadata just in case. - if(isset($index_entry->redirect)) - unset($index_entry->redirect); - if(isset($index_entry->redirect_target)) - unset($index_entry->redirect_target); - } - }); - - // Register a help section - add_help_section("25-redirect", "Redirect Pages", "

$settings->sitename supports redirect pages. To create a redirect page, enter something like # REDIRECT [[pagename]] on the first line of the redirect page's content. This must appear as the first line of the page, with no whitespace before it. You can include content beneath the redirect if you want, too (such as a reason for redirecting the page).

"); - } -]); - - - - -register_module([ - "name" => "Search", - "version" => "0.5", - "author" => "Starbeamrainbowlabs", - "description" => "Adds proper search functionality to Pepperminty Wiki using an inverted index to provide a full text search engine. If pages don't show up, then you might have hit a stop word. If not, try requesting the `invindex-rebuild` action to rebuild the inverted index from scratch.", - "id" => "feature-search", - "code" => function() { - global $settings; - - /** - * @api {get} ?action=index&page={pageName} Get an index of words for a given page - * @apiName SearchIndex - * @apiGroup Search - * @apiPermission Anonymous - * - * @apiParam {string} page The page to generate a word index page. - */ - - /* - * ██ ███ ██ ██████ ███████ ██ ██ - * ██ ████ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ █████ ███ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ████ ██████ ███████ ██ ██ - */ - add_action("index", function() { - global $settings, $env; - - $breakable_chars = "\r\n\t .,\\/!\"£$%^&*[]()+`_~#"; - - header("content-type: text/plain"); - - $source = file_get_contents("$env->storage_prefix$env->page.md"); - - $index = search::index($source); - - var_dump($env->page); - var_dump($source); - - var_dump($index); - }); - - /** - * @api {get} ?action=invindex-rebuild Rebuild the inverted search index from scratch - * @apiDescription Causes the inverted search index to be completely rebuilt from scratch. Can take a while for large wikis! - * @apiName SearchInvindexRebuild - * @apiGroup Search - * @apiPermission Anonymous - */ - - /* - * ██ ███ ██ ██ ██ ██ ███ ██ ██████ ███████ ██ ██ - * ██ ████ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ███ █████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ████ ████ ██ ██ ████ ██████ ███████ ██ ██ - * - * ██████ ███████ ██████ ██ ██ ██ ██ ██████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ █████ ██████ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ███████ ██████ ██████ ██ ███████ ██████ - */ - add_action("invindex-rebuild", function() { - search::rebuild_invindex(); - }); - - - /** - * @api {get} ?action=idindex-show Show the id index - * @apiDescription Outputs the id index. Useful if you need to verify that it's working as expected. - * @apiName SearchShowIdIndex - * @apiGroup Search - * @apiPermission Anonymous - */ - add_action("idindex-show", function() { - global $idindex; - header("content-type: application/json; charset=UTF-8"); - exit(json_encode($idindex, JSON_PRETTY_PRINT)); - }); - - /** - * @api {get} ?action=search&query={text} Search the wiki for a given query string - * @apiName Search - * @apiGroup Search - * @apiPermission Anonymous - * - * @apiParam {string} query The query string to search for. - */ - - /* - * ███████ ███████ █████ ██████ ██████ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ███████ █████ ███████ ██████ ██ ███████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ███████ ███████ ██ ██ ██ ██ ██████ ██ ██ - */ - add_action("search", function() { - global $settings, $env, $pageindex, $paths; - - // Create the inverted index if it doesn't exist. - // todo In the future perhaps a CLI for this would be good? - if(!file_exists($paths->searchindex)) - search::rebuild_invindex(); - - if(!isset($_GET["query"])) - exit(page_renderer::render("No Search Terms - Error - $settings->sitename", "

You didn't specify any search terms. Try typing some into the box above.

")); - - $search_start = microtime(true); - - $invindex = search::load_invindex($paths->searchindex); - $results = search::query_invindex($_GET["query"], $invindex); - $resultCount = count($results); - - $env->perfdata->search_time = round((microtime(true) - $search_start)*1000, 3); - - $title = $_GET["query"] . " - Search results - $settings->sitename"; - - $content = "
\n"; - $content .= "

Search Results

"; - - /// Search Box /// - $content .= "
\n"; - $content .= " \n"; - $content .= " \n"; - $content .= "
"; - - $content .= "

Found $resultCount " . ($resultCount === 1 ? "result" : "results") . " in " . $env->perfdata->search_time . "ms. "; - - $query = $_GET["query"]; - if(isset($pageindex->$query)) - { - $content .= "There's a page on $settings->sitename called $query."; - } - else - { - $content .= "There isn't a page called $query on $settings->sitename, but you "; - if((!$settings->anonedits && !$env->is_logged_in) || !$settings->editing) - { - $content .= "do not have permission to create it."; - if(!$env->is_logged_in) - { - $content .= " You could try logging in."; - } - } - else - { - $content .= "can create it."; - } - } - $content .= "

"; - - $i = 0; // todo use $_GET["offset"] and $_GET["result-count"] or something - foreach($results as $result) - { - $link = "?page=" . rawurlencode($result["pagename"]); - $pagesource = file_get_contents($env->storage_prefix . $result["pagename"] . ".md"); - - //echo("Extracting context for result " . $result["pagename"] . ".\n"); - $context = search::extract_context($_GET["query"], $pagesource); - if(strlen($context) === 0) - $context = substr($pagesource, 0, $settings->search_characters_context * 2); - //echo("'Generated search context for " . $result["pagename"] . ": $context'\n"); - $context = search::highlight_context($_GET["query"], $context); - /*if(strlen($context) == 0) - { - $context = search::strip_markup(file_get_contents("$env->page.md", null, null, null, $settings->search_characters_context * 2)); - if($pageindex->{$env->page}->size > $settings->search_characters_context * 2) - $context .= "..."; - }*/ - - // Make redirect pages italics - if(!empty($pageindex->{$result["pagename"]}->redirect)) - $result["pagename"] = "{$result["pagename"]}"; - - // We add 1 to $i here to convert it from an index to a result - // number as people expect it to start from 1 - $content .= "
\n"; - $content .= "

" . $result["pagename"] . "

\n"; - $content .= "

$context

\n"; - $content .= "
\n"; - - $i++; - } - - $content .= "
\n"; - - header("content-type: text/html; charset=UTF-8"); - exit(page_renderer::render($title, $content)); - - //header("content-type: text/plain"); - //var_dump($results); - }); - -/* - * ██████ ██ ██ ███████ ██████ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ █████ ██████ ████ █████ - * ██ ▄▄ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██████ ███████ ██ ██ ██ - * ▀▀ - * ███████ ███████ █████ ██████ ██████ ██ ██ ██ ███ ██ ██████ ███████ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ - * ███████ █████ ███████ ██████ ██ ███████ ██ ██ ██ ██ ██ ██ █████ ███ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ███████ ███████ ██ ██ ██ ██ ██████ ██ ██ ██ ██ ████ ██████ ███████ ██ ██ - */ - - /** - * @api {get} ?action=query-searchindex&query={text} Inspect the internals of the search results for a query - * @apiName Search - * @apiGroup Search - * @apiPermission Anonymous - * - * @apiParam {string} query The query string to search for. - */ - add_action("query-searchindex", function() { - global $env, $paths; - - if(empty($_GET["query"])) { - http_response_code(400); - header("content-type: text/plain"); - exit("Error: No query specified. Specify it with the 'query' GET parameter."); - } - - $env->perfdata->searchindex_decode_start = microtime(true); - $searchIndex = search::load_invindex($paths->searchindex); - $env->perfdata->searchindex_decode_time = (microtime(true) - $env->perfdata->searchindex_decode_start) * 1000; - $env->perfdata->searchindex_query_start = microtime(true); - $searchResults = search::query_invindex($_GET["query"], $searchIndex); - $env->perfdata->searchindex_query_time = (microtime(true) - $env->perfdata->searchindex_query_start) * 1000; - - header("content-type: application/json"); - $result = new stdClass(); - $result->time_format = "ms"; - $result->decode_time = $env->perfdata->searchindex_decode_time; - $result->query_time = $env->perfdata->searchindex_query_time; - $result->total_time = $result->decode_time + $result->query_time; - $result->search_results = $searchResults; - exit(json_encode($result, JSON_PRETTY_PRINT)); - }); - - /* - * ██████ ██████ ███████ ███ ██ ███████ ███████ █████ ██████ ██████ ██ ██ - * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██████ █████ ██ ██ ██ ███████ █████ ███████ ██████ ██ ███████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██ ███████ ██ ████ ███████ ███████ ██ ██ ██ ██ ██████ ██ ██ - */ - add_action("opensearch-description", function () { - global $settings; - $siteRoot = full_url() . "/index.php"; - if(!isset($_GET["debug"])) - header("content-type: application/opensearchdescription+xml"); - else - header("content-type: text/plain"); - - exit(utf8_encode(" - Search $settings->sitename - Search $settings->sitename, which is powered by Pepperminty Wiki. - $settings->sitename Wiki - $settings->favicon - Search content available under the license linked to at the bottom of the search results page. - Starbeamrainbowlabs (https://github.com/sbrl/Pepperminty-Wiki/graphs/contributors) - UTF-8 - UTF-8 - - -")); - }); - - add_action("suggest-pages", function() { - global $settings, $pageindex; - - if($settings->dynamic_page_suggestion_count === 0) - { - header("content-type: application/json"); - header("content-length: 2"); - exit("[]"); - } - - if(empty($_GET["query"])) { - http_response_code(400); - header("content-type: text/plain"); - exit("Error: You didn't specify the 'query' GET parameter."); - } - - // Rank each page name - $results = []; - foreach($pageindex as $pageName => $entry) { - $results[] = [ - "pagename" => $pageName, - // Costs: Insert: 1, Replace: 8, Delete: 6 - "distance" => levenshtein($_GET["query"], $pageName, 1, 8, 6) - ]; - } - - // Sort the page names by distance form the original query - usort($results, function($a, $b) { - if($a["distance"] == $b["distance"]) - return strcmp($a["pagename"], $b["pagename"]); - return $a["distance"] < $b["distance"] ? -1 : 1; - }); - - // Send the results to the user - header("content-type: application/json"); - exit(json_encode(array_slice($results, 0, $settings->dynamic_page_suggestion_count))); - }); - - if($settings->dynamic_page_suggestion_count > 0) - { - page_renderer::AddJSSnippet('/// Dynamic page suggestion system -// Micro snippet 8 - Promisified GET (fetched 20th Nov 2016) -function get(u){return new Promise(function(r,t,a){a=new XMLHttpRequest();a.onload=function(b,c){b=a.status;c=a.response;if(b>199&&b<300){r(c)}else{t(c)}};a.open("GET",u,true);a.send(null)})} - -window.addEventListener("load", function(event) { - var searchBox = document.querySelector("input[type=search]"); - searchBox.dataset.lastValue = ""; - searchBox.addEventListener("keyup", function(event) { - // Make sure that we don\'t keep sending requests to the server if nothing has changed - if(searchBox.dataset.lastValue == event.target.value) - return; - searchBox.dataset.lastValue = event.target.value; - // Fetch the suggestions from the server - get("?action=suggest-pages&query=" + encodeURIComponent(event.target.value)).then(function(response) { - var suggestions = JSON.parse(response), - dataList = document.getElementById("allpages"); - - // If the server sent no suggestions, then we shouldn\'t replace the contents of the datalist - if(suggestions.length == 0) - return; - - console.info(`Fetched suggestions for ${event.target.value}:`, suggestions.map(s => s.pagename)); - - // Remove all the existing suggestions - while(dataList.firstChild) { - dataList.removeChild(dataList.firstChild); - } - - // Add the new suggestions to the datalist - var optionsFrag = document.createDocumentFragment(); - suggestions.forEach(function(suggestion) { - var suggestionElement = document.createElement("option"); - suggestionElement.value = suggestion.pagename; - suggestionElement.dataset.distance = suggestion.distance; - optionsFrag.appendChild(suggestionElement); - }); - dataList.appendChild(optionsFrag); - }); - }); -}); -'); - } - } -]); - -class search -{ - // Words that we should exclude from the inverted index - public static $stop_words = [ - "a", "about", "above", "above", "across", "after", "afterwards", "again", - "against", "all", "almost", "alone", "along", "already", "also", - "although", "always", "am", "among", "amongst", "amoungst", "amount", - "an", "and", "another", "any", "anyhow", "anyone", "anything", "anyway", - "anywhere", "are", "around", "as", "at", "back", "be", "became", - "because", "become", "becomes", "becoming", "been", "before", - "beforehand", "behind", "being", "below", "beside", "besides", - "between", "beyond", "bill", "both", "bottom", "but", "by", "call", - "can", "cannot", "cant", "co", "con", "could", "couldnt", "cry", "de", - "describe", "detail", "do", "done", "down", "due", "during", "each", - "eg", "eight", "either", "eleven", "else", "elsewhere", "empty", - "enough", "etc", "even", "ever", "every", "everyone", "everything", - "everywhere", "except", "few", "fill", "find", - "fire", "first", "five", "for", "former", "formerly", "found", - "four", "from", "front", "full", "further", "get", "give", "go", "had", - "has", "hasnt", "have", "he", "hence", "her", "here", "hereafter", - "hereby", "herein", "hereupon", "hers", "herself", "him", "himself", - "his", "how", "however", "ie", "if", "in", "inc", "indeed", - "interest", "into", "is", "it", "its", "itself", "keep", "last", - "latter", "latterly", "least", "less", "ltd", "made", "many", "may", - "me", "meanwhile", "might", "mine", "more", "moreover", "most", - "mostly", "move", "much", "must", "my", "myself", "name", "namely", - "neither", "never", "nevertheless", "next", "nine", "no", "none", - "nor", "not", "nothing", "now", "nowhere", "of", "off", "often", "on", - "once", "one", "only", "onto", "or", "other", "others", "otherwise", - "our", "ours", "ourselves", "out", "over", "own", "part", "per", - "perhaps", "please", "put", "rather", "re", "same", "see", "seem", - "seemed", "seeming", "seems", "serious", "several", "she", "should", - "show", "side", "since", "sincere", "six", "sixty", "so", "some", - "somehow", "someone", "something", "sometime", "sometimes", - "somewhere", "still", "such", "system", "take", "ten", "than", "that", - "the", "their", "them", "themselves", "then", "thence", "there", - "thereafter", "thereby", "therefore", "therein", "thereupon", "these", - "they", "thickv", "thin", "third", "this", "those", "though", "three", - "through", "throughout", "thru", "thus", "to", "together", "too", "top", - "toward", "towards", "twelve", "twenty", "two", "un", "under", "until", - "up", "upon", "us", "very", "via", "was", "we", "well", "were", "what", - "whatever", "when", "whence", "whenever", "where", "whereafter", - "whereas", "whereby", "wherein", "whereupon", "wherever", "whether", - "which", "while", "whither", "who", "whoever", "whole", "whom", "whose", - "why", "will", "with", "within", "without", "would", "yet", "you", - "your", "yours", "yourself", "yourselves" - ]; - - public static function index($source) - { - $source = html_entity_decode($source, ENT_QUOTES); - $source_length = strlen($source); - - $index = []; - - $terms = self::tokenize($source); - $i = 0; - foreach($terms as $term) - { - $nterm = $term; - - // Skip over stop words (see https://en.wikipedia.org/wiki/Stop_words) - if(in_array($nterm, self::$stop_words)) continue; - - if(!isset($index[$nterm])) - { - $index[$nterm] = [ "freq" => 0, "offsets" => [] ]; - } - - $index[$nterm]["freq"]++; - $index[$nterm]["offsets"][] = $i; - - $i++; - } - - return $index; - } - - public static function tokenize($source) - { - $source = strtolower($source); - $source = str_replace([ '[', ']', '|', '{', '}' ], " ", $source); - return preg_split("/((^\p{P}+)|(\p{P}*\s+\p{P}*)|(\p{P}+$))|\|/u", $source, -1, PREG_SPLIT_NO_EMPTY); - } - - public static function strip_markup($source) - { - return str_replace([ "[", "]", "\"", "*", "_", " - ", "`" ], "", $source); - } - - public static function rebuild_invindex() - { - global $pageindex, $env, $paths; - - header("content-type: text/event-stream"); - - // Clear the id index out - ids::clear(); - - // Reindex each page in turn - $invindex = []; - foreach($pageindex as $pagename => $pagedetails) - { - $pagesource = utf8_encode(file_get_contents("$env->storage_prefix$pagename.md")); - $index = self::index($pagesource); - - $pageid = ids::getid($pagename); - self::merge_into_invindex($invindex, $pageid, $index); - - echo("Added $pagename (id #$pageid) to the new search index.\n\n"); - flush(); - } - - echo("Search index rebuilding complete.\n\n"); - echo("Saving new search index to '$paths->searchindex'.\n\n"); - - self::save_invindex($paths->searchindex, $invindex); - } - - /* - * @summary Sorts an index alphabetically. Will also sort an inverted index. - * This allows us to do a binary search instead of a regular - * sequential search. - */ - public static function sort_index(&$index) - { - ksort($index, SORT_NATURAL); - } - - /* - * @summary Compares two *regular* indexes to find the differences between them. - * - * @param {array} $indexa - The old index. - * @param {array} $indexb - The new index. - * @param {array} $changed - An array to be filled with the nterms of all - * the changed entries. - * @param {array} $removed - An array to be filled with the nterms of all - * the removed entries. - */ - public static function compare_indexes($oldindex, $newindex, &$changed, &$removed) - { - foreach($oldindex as $nterm => $entry) - { - if(!isset($newindex[$nterm])) - $removed[] = $nterm; - } - foreach($newindex as $nterm => $entry) - { - if(!isset($oldindex[$nterm]) or // If this world is new - $newindex[$nterm] !== $oldindex[$nterm]) // If this word has changed - $changed[$nterm] = $newindex[$nterm]; - } - } - - /* - * @summary Reads in and parses an inverted index. - */ - // Todo remove this function and make everything streamable - public static function load_invindex($invindex_filename) { - $invindex = json_decode(file_get_contents($invindex_filename), true); - return $invindex; - } - - public static function measure_invindex_load_time($invindex_filename) { - global $env; - - $searchindex_decode_start = microtime(true); - search::load_invindex($invindex_filename); - $env->perfdata->searchindex_decode_time = round((microtime(true) - $searchindex_decode_start)*1000, 3); - } - - /* - * @summary Merge an index into an inverted index. - */ - public static function merge_into_invindex(&$invindex, $pageid, &$index, &$removals = []) - { - // Remove all the subentries that were removed since last time - foreach($removals as $nterm) - { - unset($invindex[$nterm][$pageid]); - } - - // Merge all the new / changed index entries into the inverted index - foreach($index as $nterm => $newentry) - { - // If the nterm isn't in the inverted index, then create a space for it - if(!isset($invindex[$nterm])) $invindex[$nterm] = []; - $invindex[$nterm][$pageid] = $newentry; - - // Sort the page entries for this word by frequency - uasort($invindex[$nterm], function($a, $b) { - if($a["freq"] == $b["freq"]) return 0; - return ($a["freq"] < $b["freq"]) ? +1 : -1; - }); - } - - // Sort the inverted index by rank - uasort($invindex, function($a, $b) { - $ac = count($a); $bc = count($b); - if($ac == $bc) return 0; - return ($ac < $bc) ? +1 : -1; - }); - } - - /** - * Deletes the given pageid from the given pageindex. - * @param inverted_index &$invindex The inverted index. - * @param number $pageid The pageid to remove. - */ - public static function delete_entry(&$invindex, $pageid) - { - $str_pageid = (string)$pageid; - foreach($invindex as $nterm => &$entry) - { - if(isset($entry[$pageid])) - unset($entry[$pageid]); - if(isset($entry[$str_pageid])) - unset($entry[$str_pageid]); - if(count($entry) === 0) - unset($invindex[$nterm]); - } - } - - public static function save_invindex($filename, &$invindex) - { - file_put_contents($filename, json_encode($invindex)); - } - - public static function query_invindex($query, &$invindex) - { - global $settings, $pageindex; - - $query_terms = self::tokenize($query); - $matching_pages = []; - - - // Loop over each term in the query and find the matching page entries - $count = count($query_terms); - for($i = 0; $i < $count; $i++) - { - $qterm = $query_terms[$i]; - - // Stop words aren't worth the bother - make sure we don't search - // the title or the tags for them - if(in_array($qterm, self::$stop_words)) - continue; - - // Only search the inverted index if it actually exists there - if(isset($invindex[$qterm])) - { - // Loop over each page in the inverted index entry - foreach($invindex[$qterm] as $pageid => $page_entry) - { - // Create an entry in the matching pages array if it doesn't exist - if(!isset($matching_pages[$pageid])) - $matching_pages[$pageid] = [ "nterms" => [] ]; - $matching_pages[$pageid]["nterms"][$qterm] = $page_entry; - } - } - - - // Loop over the pageindex and search the titles / tags - foreach ($pageindex as $pagename => $pagedata) - { - // Get the current page's id - $pageid = ids::getid($pagename); - // Consider matches in the page title - if(stripos($pagename, $qterm) !== false) - { - // We found the qterm in the title - if(!isset($matching_pages[$pageid])) - $matching_pages[$pageid] = [ "nterms" => [] ]; - - // Set up a counter for page title matches if it doesn't exist already - if(!isset($matching_pages[$pageid]["title-matches"])) - $matching_pages[$pageid]["title-matches"] = 0; - - $matching_pages[$pageid]["title-matches"] += count(mb_stripos_all($pagename, $qterm)); - } - - // Consider matches in the page's tags - if(isset($pagedata->tags) and // If this page has tags - stripos(implode(" ", $pagedata->tags), $qterm) !== false) // And we found the qterm in the tags - { - if(!isset($matching_pages[$pageid])) - $matching_pages[$pageid] = [ "nterms" => [] ]; - - // Set up a counter for tag match if there isn't one already - if(!isset($matching_pages[$pageid]["tag-matches"])) - $matching_pages[$pageid]["tag-matches"] = 0; - $matching_pages[$pageid]["tag-matches"] += count(mb_stripos_all(implode(" ", $pagedata->tags), $qterm)); - } - } - } - - - foreach($matching_pages as $pageid => &$pagedata) - { - $pagedata["pagename"] = ids::getpagename($pageid); - $pagedata["rank"] = 0; - - $pageOffsets = []; - - // Loop over each search term found on this page - foreach($pagedata["nterms"] as $pterm => $entry) - { - // Add the number of occurrences of this search term to the ranking - $pagedata["rank"] += $entry["freq"]; - - // Add the offsets to a listof all offsets on this page - foreach($entry["offsets"] as $offset) - $pageOffsets[] = $offset; - } - /* - // Sort the list of offsets - $pageOffsets = array_unique($pageOffsets); - sort($pageOffsets); - var_dump($pageOffsets); - - // Calcualate the clump distances via a variable moving window size - $pageOffsetsCount = count($pageOffsets); - $clumpDistanceWindow = min($count, $pageOffsetsCount); // a.k.a. count($query_terms) - see above - $clumpDistances = []; - for($i = 0; $i < $pageOffsetsCount - $clumpDistanceWindow; $i++) - $clumpDistances[] = $pageOffsets[$i] - $pageOffsets[$i + $clumpDistanceWindow]; - - // Sort the new list of clump distances - sort($clumpDistances); - // Calcualate a measureof how clumped the offsets are - $tightClumpLimit = floor((count($clumpDistances) - 1) / 0.25); - $tightClumpsMeasure = $clumpDistances[$tightClumpLimit] - $clumpDistances[0]; - $clumpsRange = $clumpDistances[count($clumpDistances) - 1] - $clumpDistances[0]; - - $clumpiness = $tightClumpsMeasure / $clumpsRange; - echo("{$pagedata["pagename"]} - $clumpiness"); - */ - - // Consider matches in the title / tags - if(isset($pagedata["title-matches"])) - $pagedata["rank"] += $pagedata["title-matches"] * $settings->search_title_matches_weighting; - if(isset($pagedata["tag-matches"])) - $pagedata["rank"] += $pagedata["tag-matches"] * $settings->search_tags_matches_weighting; - - // todo remove items if the rank is below a threshold - } - - // todo sort by rank here - uasort($matching_pages, function($a, $b) { - if($a["rank"] == $b["rank"]) return 0; - return ($a["rank"] < $b["rank"]) ? +1 : -1; - }); - - return $matching_pages; - } - - public static function extract_context($query, $source) - { - global $settings; - - $nterms = self::tokenize($query); - $matches = []; - // Loop over each nterm and find it in the source - foreach($nterms as $nterm) - { - if(in_array($nterm, static::$stop_words)) - continue; - $all_offsets = mb_stripos_all($source, $nterm); - // Skip over adding matches if there aren't any - if($all_offsets === false) - continue; - foreach($all_offsets as $offset) - { - $matches[] = [ $nterm, $offset ]; - } - } - - // Sort the matches by offset - usort($matches, function($a, $b) { - if($a[1] == $b[1]) return 0; - return ($a[1] > $b[1]) ? +1 : -1; - }); - - $sourceLength = strlen($source); - - $contexts = []; - $basepos = 0; - $matches_count = count($matches); - while($basepos < $matches_count) - { - // Store the next match along - all others will be relative to that one - $group = [$matches[$basepos]]; - - // Start scanning at the next one along - we always store the first match - $scanpos = $basepos + 1; - $distance = 0; - - while(true) - { - // Break out if we reach the end - if($scanpos >= $matches_count) break; - - // Find the distance between the current one and the last one - $distance = $matches[$scanpos][1] - $matches[$scanpos - 1][1]; - - // Store it if the distance is below the threshold - if($distance < $settings->search_characters_context) - $group[] = $matches[$scanpos]; - else - break; - - $scanpos++; - } - - $context_start = $group[0][1] - $settings->search_characters_context; - $context_end = $group[count($group) - 1][1] + $settings->search_characters_context; - - if($context_start < 0) $context_start = 0; - if($context_end > $sourceLength) $context_end = $sourceLength; - - //echo("Got context. Start: $context_start, End: $context_end\n"); - //echo("Group:"); var_dump($group); - - $context = substr($source, $context_start, $context_end - $context_start); - - // Strip the markdown from the context - it's most likely going to - // be broken anyway. - //$context = self::strip_markup($context); - - // Escape special characters to protect against attacks - $context = htmlentities($context); - - $contexts[] = $context; - - $basepos = $scanpos + 1; - } - - return implode(" ... ", $contexts); - } - - public static function highlight_context($query, $context) - { - $qterms = self::tokenize($query); - - foreach($qterms as $qterm) - { - if(in_array($qterm, static::$stop_words)) - continue; - // From http://stackoverflow.com/a/2483859/1460422 - $context = preg_replace("/" . str_replace("/", "\/", preg_quote($qterm)) . "/i", "$0", $context); - } - - return $context; - } -} - - - - -register_module([ - "name" => "Uploader", - "version" => "0.5.8", - "author" => "Starbeamrainbowlabs", - "description" => "Adds the ability to upload files to Pepperminty Wiki. Uploaded files act as pages and have the special 'File/' prefix.", - "id" => "feature-upload", - "code" => function() { - global $settings; - /** - * @api {get} ?action=upload Get a page to let you upload a file. - * @apiName UploadFilePage - * @apiGroup Upload - * @apiPermission User - */ - - /** - * @api {post} ?action=upload Upload a file - * @apiName UploadFile - * @apiGroup Upload - * @apiPermission User - * - * @apiParam {string} name The name of the file to upload. - * @apiParam {string} description A description of the file. - * @apiParam {file} file The file to upload. - * - * @apiUse UserNotLoggedInError - * @apiError UploadsDisabledError Uploads are currently disabled in the wiki's settings. - * @apiError UnknownFileTypeError The type of the file you uploaded is not currently allowed in the wiki's settings. - * @apiError ImageDimensionsFiledError PeppermintyWiki couldn't obtain the dimensions of the image you uploaded. - * @apiError DangerousFileError The file uploaded appears to be dangerous. - * @apiError DuplicateFileError The filename specified is a duplicate of a file that already exists. - * @apiError FileTamperedError Pepperminty Wiki couldn't verify that the file wasn't tampered with during theupload process. - */ - - /* - * ██ ██ ██████ ██ ██████ █████ ██████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██████ ██ ██ ██ ███████ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██ ███████ ██████ ██ ██ ██████ - */ - add_action("upload", function() { - global $settings, $env, $pageindex, $paths; - - switch($_SERVER["REQUEST_METHOD"]) - { - case "GET": - // Send upload page - - if(!$settings->upload_enabled) - exit(page_renderer::render("Upload Disabled - $setting->sitename", "

You can't upload anything at the moment because $settings->sitename has uploads disabled. Try contacting $settings->admindetails_name, your site Administrator. Go back.

")); - if(!$env->is_logged_in) - exit(page_renderer::render("Upload Error - $settings->sitename", "

You are not currently logged in, so you can't upload anything.

-

Try logging in first.

")); - - exit(page_renderer::render("Upload - $settings->sitename", "

Upload file

-

Select an image below, and then type a name for it in the box. This server currently supports uploads up to " . human_filesize(get_max_upload_size()) . " in size.

-

$settings->sitename currently supports uploading of the following file types: " . implode(", ", $settings->upload_allowed_file_types) . ".

-
- - -
- - -
- - -

$settings->editing_message

- -
- ")); - - break; - - case "POST": - // Recieve file - - // Make sure uploads are enabled - if(!$settings->upload_enabled) - { - 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) - { - 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.")); - } - - // Calculate the target name, removing any characters we - // are unsure about. - $target_name = makepathsafe($_POST["name"]); - $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 - switch(substr($mime_type, 0, strpos($mime_type, "/"))) - { - case "image": - $extra_data = []; - // Check SVG uploads with a special function - $imagesize = $mime_type !== "image/svg+xml" ? getimagesize($temp_filename, $extra_data) : upload_check_svg($temp_filename); - - // Make sure that the image size is defined - if(!is_int($imagesize[0]) or !is_int($imagesize[1])) - { - http_response_code(415); - exit(page_renderer::render("Upload Error - $settings->sitename", "

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" ])) - { - 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.

")); - } - - $new_filename = "$paths->upload_file_prefix$target_name.$file_extension"; - $new_description_filename = "$new_filename.md"; - - if(isset($pageindex->$new_filename)) - exit(page_renderer::render("Upload Error - $settings->sitename", "

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.

")); - - if(!file_exists($env->storage_prefix . "Files")) - mkdir($env->storage_prefix . "Files", 0775); - - 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 $settings->sitename has been attacked. Please contact " . $settings->admindetails_name . ", your $settings->sitename Administrator.

")); - } - - $description = $_POST["description"]; - - // 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); - $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_filename = $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_filename, - "user" => $env->user, - "filesize" => filesize($env->storage_prefix . $entry->uploadedfilepath) - ]); - } - - header("location: ?action=view&page=$new_filename&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 = $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\""); - - // If the size is set or 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"; - - $preview_sizes = [ 256, 512, 768, 1024, 1440 ]; - $preview_html .= "\t\t\t
- - \n\t\t\t
"; - break; - - case "video": - $preview_html .= "\t\t\t
- -
"; - break; - - case "audio": - $preview_html .= "\t\t\t
- -
"; - } - - $fileInfo = []; - $fileInfo["Name"] = str_replace("File/", "", $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; - - $preview_html .= "\t\t\t

File Information

- "; - foreach ($fileInfo as $displayName => $displayValue) - { - $preview_html .= "\n"; - } - $preview_html .= "
$displayName$displayValue
"; - - $parts["{content}"] = str_replace("", "\n$preview_html", $parts["{content}"]); - } - }); - - // Register a section on the help page on uploading files - add_help_section("28-uploading-files", "Uploading Files", "

$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.

"); - } -]); - -//// Pair of functions to calculate the actual maximum upload size supported by the server -//// Lifted from Drupal by @meustrus from Stackoverflow. Link to answer: -//// http://stackoverflow.com/a/25370978/1460422 -// Returns a file size limit in bytes based on the PHP upload_max_filesize -// and post_max_size -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; -} - -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); - } -} - -function upload_check_svg($temp_filename) -{ - global $settings; - // Check for script tags - if(strpos(file_get_contents($temp_filename), "sitename", "

$settings->sitename detected that you uploaded an SVG image and performed some extra security checks on your file. Whilst performing these checks it was discovered that the file you uploaded contains some Javascript, which could be dangerous. 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.

")); - } - - // Find and return the size of the SVG image - return getsvgsize($temp_filename); -} - -function getsvgsize($svgFilename) -{ - $svg = simplexml_load_file($svgFilename); // Load it as XML - if($svg === false) - { - http_response_code(415); - exit(page_renderer::render("Upload Error - $settings->sitename", "

When $settings->sitename tried to open your SVG file for checking, it found some invalid syntax. The uploaded file has been discarded. Go back to try again.

")); - } - $rootAttrs = $svg->attributes(); - $imageSize = false; - if(isset($rootAttrs->width) and isset($rootAttrs->height)) - $imageSize = [ intval($rootAttrs->width), intval($rootAttrs->height) ]; - else if(isset($rootAttrs->viewBox)) - $imageSize = array_map("intval", array_slice(explode(" ", $rootAttrs->viewBox), -2, 2)); - - return $imageSize; -} - -function errorimage($text, $target_size = null) -{ - $width = 640; - $height = 480; - - if(!empty($target_size)) - { - $width = $target_size; - $height = $target_size * (2 / 3); - } - - $image = imagecreatetruecolor($width, $height); - imagefill($image, 0, 0, imagecolorallocate($image, 238, 232, 242)); // Set the background to #eee8f2 - $fontwidth = imagefontwidth(3); - imagestring($image, 3, - ($width / 2) - (($fontwidth * strlen($text)) / 2), - ($height / 2) - (imagefontheight(3) / 2), - $text, - imagecolorallocate($image, 17, 17, 17) // #111111 - ); - - return $image; -} - - - - -register_module([ - "name" => "User Preferences", - "version" => "0.2.1", - "author" => "Starbeamrainbowlabs", - "description" => "Adds a user preferences page, letting pople do things like change their email address and password.", - "id" => "feature-user-preferences", - "code" => function() { - global $env, $settings; - /** - * @api {get} ?action=user-preferences Get a user preferences configuration page. - * @apiName UserPreferences - * @apiGroup Settings - * @apiPermission User - */ - - /* - * ██ ██ ███████ ███████ ██████ - * ██ ██ ██ ██ ██ ██ - * ██ ██ ███████ █████ ██████ █████ - * ██ ██ ██ ██ ██ ██ - * ██████ ███████ ███████ ██ ██ - * - * ██████ ██████ ███████ ███████ ███████ - * ██ ██ ██ ██ ██ ██ ██ - * ██████ ██████ █████ █████ ███████ - * ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ███████ ██ ███████ - */ - add_action("user-preferences", function() { - global $env, $settings; - - if(!$env->is_logged_in) - { - exit(page_renderer::render_main("Error - $settings->sitename", "

Since you aren't logged in, you can't change your preferences. This is because stored preferences are tied to each registered user account. You can login here.

")); - } - - $statusMessages = [ - "change-password" => "Password changed successfully!" - ]; - - if(!isset($env->user_data->emailAddress)) { - $env->user_data->emailAddress = ""; - save_userdata(); - } - - $content = "

User Preferences

\n"; - if(isset($_GET["success"]) && $_GET["success"] === "yes") - { - $content .= "

" . $statusMessages[$_GET["operation"]] . "

\n"; - } - $content .= "\n"; - $content .= "\n"; - $content .= "
\n"; - $content .= " \n"; - $content .= " \n"; - $content .= "

Used to send you notifications etc. Never shared with anyone except $settings->admindetails_name, $settings->sitename's administrator.

\n"; - $content .= " \n"; - $content .= "
\n"; - $content .= "

Change Password"; - $content .= "
\n"; - $content .= " \n"; - $content .= " \n"; - $content .= "
\n"; - $content .= " \n"; - $content .= " \n"; - $content .= "
\n"; - $content .= " \n"; - $content .= " \n"; - $content .= "
\n"; - $content .= " \n"; - $content .= "
\n"; - - if($env->is_admin) - $content .= "

As an admin, you can also edit $settings->sitename's master settings.

\n"; - - exit(page_renderer::render_main("User Preferences - $settings->sitename", $content)); - }); - - add_action("save-preferences", function() { - global $env, $settings; - - if(!$env->is_logged_in) - { - http_response_code(400); - exit(page_renderer::render_main("Error Saving Preferences - $settings->sitename", "

You aren't logged in, so you can't save your preferences. Try logging in first.

")); - } - - if(isset($_POST["email-address"])) - { - if(mb_strlen($_POST["email-address"]) > 320) - { - http_response_code(413); - exit(page_renderer::render_main("Error Saving Email Address - $settings->sitename", "

The email address you supplied ({$_POST['email-address']}) is too long. Email addresses can only be 320 characters long. Go back.")); - } - - if(mb_strpos($_POST["email-address"], "@") === false) - { - http_response_code(422); - exit(page_renderer::render_main("Error Saving Email Address - $settings->sitename", "

The email address you supplied ({$_POST['email-address']}) doesn't appear to be valid. Go back.")); - } - - $env->user_data->emailAddress = $_POST["email-address"]; - } - - // Save the user's preferences - if(!save_userdata()) - { - http_response_code(503); - exit(page_renderer::render_main("Error Saving Preferences - $settings->sitename", "

$settings->sitename had some trouble saving your preferences! Please contact $settings->admindetails_name, $settings->sitename's administrator and tell them about this error if it still occurs in 5 minutes. They can be contacted by email at this address: " . hide_email($settings->admindetails_email) . ".

")); - } - - exit(page_renderer::render_main("Preferences Saved Successfully - $settings->sitename", "

Your preferences have been saved successfully! You could go back your preferences page, or on to the $settings->defaultpage.

")); - }); - - /** - * @api {post} ?action=change-password Change your password - * @apiName ChangePassword - * @apiGroup Settings - * @apiPermission User - * - * @apiParam {string} current-pass Your current password. - * @apiParam {string} new-pass Your new password. - * @apiParam {string} new-pass-confirm Your new password again, to make sure you've typed it correctly. - * - * @apiError PasswordMismatchError The new password fields don't match. - */ - add_action("change-password", function() { - global $env, $settings; - - // Make sure the new password was typed correctly - // This comes before the current password check since that's more intensive - if($_POST["new-pass"] !== $_POST["new-pass-confirm"]) { - exit(page_renderer::render_main("Password mismatch - $settings->sitename", "

The new password you typed twice didn't match! Go back.

")); - } - // Check the current password - if(hash_password($_POST["current-pass"]) !== $env->user_data->password) { - exit(page_renderer::render_main("Password mismatch - $settings->sitename", "

Error: You typed your current password incorrectly! Go back.

")); - } - - // All's good! Go ahead and change the password. - $env->user_data->password = hash_password($_POST["new-pass"]); - // Save the userdata back to disk - save_userdata(); - - http_response_code(307); - header("location: ?action=user-preferences&success=yes&operation=change-password"); - exit(page_renderer::render_main("Password Changed Successfully", "

You password was changed successfully. Go back to the user preferences page.

")); - }); - - // Display a help section on the user preferences, but only if the user - // is logged in and so able to access them - if($env->is_logged_in) - { - add_help_section("910-user-preferences", "User Preferences", "

As you are logged in, $settings->sitename lets you configure a selection of personal preferences. These can be viewed and tweaked to you liking over on the preferences page, which can be accessed at any time by clicking the cog icon (it looks something like this: $settings->user_preferences_button_text), though the administrator of $settings->sitename ($settings->admindetails_name) may have changed its appearance.

"); - } - } -]); - - - + + +register_module([ + "name" => "Recent Changes", + "version" => "0.3.3", + "author" => "Starbeamrainbowlabs", + "description" => "Adds recent changes. Access through the 'recent-changes' action.", + "id" => "feature-recent-changes", + "code" => function() { + global $settings, $env, $paths; + /** + * @api {get} ?action=recentchanges Get a list of recent changes + * @apiName RecentChanges + * @apiGroup Stats + * @apiPermission Anonymous + */ + + // Add the recent changes json file to $paths for convenience. + $paths->recentchanges = $env->storage_prefix . "recent-changes.json"; + // Create the recent changes json file if it doesn't exist + if(!file_exists($paths->recentchanges)) + file_put_contents($paths->recentchanges, "[]"); + + /* + * ██████ ███████ ██████ ███████ ███ ██ ████████ + * ██ ██ ██ ██ ██ ████ ██ ██ + * ██████ █████ ██ █████ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ███████ ██████ ███████ ██ ████ ██ + * + * ██████ ██ ██ █████ ███ ██ ██████ ███████ ███████ + * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ + * ██ ███████ ███████ ██ ██ ██ ██ ███ █████ ███████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██ ██ ██ ██ ██ ████ ██████ ███████ ███████ + */ + add_action("recent-changes", function() { + global $settings, $paths, $pageindex; + + $content = "\t\t

Recent Changes

\n"; + + $recent_changes = json_decode(file_get_contents($paths->recentchanges)); + + if(count($recent_changes) > 0) + { + $content .= render_recent_changes($recent_changes); + } + else + { + // No changes yet :( + $content .= "

None yet! Try making a few changes and then check back here.

\n"; + } + + exit(page_renderer::render("Recent Changes - $settings->sitename", $content)); + }); + + register_save_preprocessor(function(&$pageinfo, &$newsource, &$oldsource) { + global $env, $settings, $paths; + + // Work out the old and new page lengths + $oldsize = strlen($oldsource); + $newsize = strlen($newsource); + // Calculate the page length difference + $size_diff = $newsize - $oldsize; + + $newchange = [ + "type" => "edit", + "timestamp" => time(), + "page" => $env->page, + "user" => $env->user, + "newsize" => $newsize, + "sizediff" => $size_diff + ]; + if($oldsize == 0) + $newchange["newpage"] = true; + + add_recent_change($newchange); + }); + + add_help_section("800-raw-page-content", "Recent Changes", "

The recent changes page displays a list of all the most recent changes that have happened around $settings->sitename, arranged in chronological order. It can be found in the \"More...\" menu in the top right by default.

+

Each entry displays the name of the page in question, who edited it, how long ago they did so, and the number of characters added or removed. Pages that currently redirect to another page are shown in italics, and hovering over the time since the edit wil show the exact time that the edit was made.

"); + } +]); + +/** + * Adds a new recent change to the recent changes file. + * @param array $rchange The new change to add. + */ +function add_recent_change($rchange) +{ + global $settings, $paths; + + $recentchanges = json_decode(file_get_contents($paths->recentchanges), true); + array_unshift($recentchanges, $rchange); + + // Limit the number of entries in the recent changes file if we've + // been asked to. + if(isset($settings->max_recent_changes)) + $recentchanges = array_slice($recentchanges, 0, $settings->max_recent_changes); + + // Save the recent changes file back to disk + file_put_contents($paths->recentchanges, json_encode($recentchanges, JSON_PRETTY_PRINT)); +} + +function render_recent_changes($recent_changes) +{ + global $pageindex; + + // Cache the number of recent changes we are dealing with + $rchange_count = count($recent_changes); + + // Group changes made on the same page and the same day together + for($i = 0; $i < $rchange_count; $i++) + { + for($s = $i + 1; $s < $rchange_count; $s++) + { + // Break out if we have reached the end of the day we are scanning + if(date("dmY", $recent_changes[$i]->timestamp) !== date("dmY", $recent_changes[$s]->timestamp)) + break; + + // If we have found a change that has been made on the same page and + // on the same day as the one that we are scanning for, move it up + // next to the change we are scanning for. + if($recent_changes[$i]->page == $recent_changes[$s]->page && + date("j", $recent_changes[$i]->timestamp) === date("j", $recent_changes[$s]->timestamp)) + { + // FUTURE: We may need to remove and insert instead of swapping changes around if this causes some changes to appear out of order. + $temp = $recent_changes[$i + 1]; + $recent_changes[$i + 1] = $recent_changes[$s]; + $recent_changes[$s] = $temp; + $i++; + } + } + } + + $content = "
    \n"; + $last_time = 0; + for($i = 0; $i < $rchange_count; $i++) + { + $rchange = $recent_changes[$i]; + + if($last_time !== date("dmY", $rchange->timestamp)) + $content .= "
  • " . date("jS F", $rchange->timestamp) . "

  • \n"; + + $rchange_results = []; + for($s = $i; $s < $rchange_count; $s++) + { + if($recent_changes[$s]->page !== $rchange->page) + break; + + $rchange_results[$s] = render_recent_change($recent_changes[$s]); + $i++; + } + // Take one from i to account for when we tick over to the next + // iteration of the main loop + $i -= 1; + + $next_entry = implode("\n", $rchange_results); + // If the change count is greater than 1, then we should enclose it + // in a
    tag. + if(count($rchange_results) > 1) + { + reset($rchange_results); + $rchange_first = $recent_changes[key($rchange_results)]; + end($rchange_results); + $rchange_last = $recent_changes[key($rchange_results)]; + + $pageDisplayHtml = render_pagename($rchange_first); + $timeDisplayHtml = render_timestamp($rchange_first->timestamp); + $users = []; + foreach($rchange_results as $key => $rchange_result) + { + if(!in_array($recent_changes[$key]->user, $users)) + $users[] = $recent_changes[$key]->user; + } + foreach($users as &$user) + $user = page_renderer::render_username($user); + $userDisplayHtml = render_editor(implode(", ", $users)); + + $next_entry = "
  • $pageDisplayHtml $userDisplayHtml $timeDisplayHtml
      $next_entry
  • "; + + $content .= "$next_entry\n"; + } + else + { + $content .= implode("\n", $rchange_results); + } + + $last_time = date("dmY", $rchange->timestamp); + } + $content .= "\t\t
"; + + return $content; +} + +function render_recent_change($rchange) +{ + global $pageindex; + $pageDisplayHtml = render_pagename($rchange); + $editorDisplayHtml = render_editor(page_renderer::render_username($rchange->user)); + $timeDisplayHtml = render_timestamp($rchange->timestamp); + + $revisionId = false; + if(isset($pageindex->{$rchange->page}) && isset($pageindex->{$rchange->page}->history)) + { + foreach($pageindex->{$rchange->page}->history as $historyEntry) + { + if($historyEntry->timestamp == $rchange->timestamp) + { + $revisionId = $historyEntry->rid; + break; + } + } + } + + $result = ""; + $resultClasses = []; + switch(isset($rchange->type) ? $rchange->type : "edit") + { + case "edit": + // The number (and the sign) of the size difference to display + $size_display = ($rchange->sizediff > 0 ? "+" : "") . $rchange->sizediff; + $size_display_class = $rchange->sizediff > 0 ? "larger" : ($rchange->sizediff < 0 ? "smaller" : "nochange"); + if($rchange->sizediff > 500 or $rchange->sizediff < -500) + $size_display_class .= " significant"; + + + $size_title_display = human_filesize($rchange->newsize - $rchange->sizediff) . " -> " . human_filesize($rchange->newsize); + + if(!empty($rchange->newpage)) + $resultClasses[] = "newpage"; + + $result .= "$pageDisplayHtml $editorDisplayHtml $timeDisplayHtml ($size_display)"; + break; + + case "deletion": + $resultClasses[] = "deletion"; + $result .= "$pageDisplayHtml $editorDisplayHtml $timeDisplayHtml"; + break; + + case "upload": + $resultClasses[] = "upload"; + $result .= "$pageDisplayHtml $editorDisplayHtml $timeDisplayHtml (" . human_filesize($rchange->filesize) . ")"; + break; + case "comment": + $resultClasses[] = "new-comment"; + $result .= "$pageDisplayHtml $editorDisplayHtml"; + } + + $resultAttributes = " " . (count($resultClasses) > 0 ? "class='" . implode(" ", $resultClasses) . "'" : ""); + $result = "\t\t\t$result\n"; + + return $result; +} + + + + +register_module([ + "name" => "Redirect pages", + "version" => "0.3", + "author" => "Starbeamrainbowlabs", + "description" => "Adds support for redirect pages. Uses the same syntax that Mediawiki does.", + "id" => "feature-redirect", + "code" => function() { + global $settings; + + register_save_preprocessor(function(&$index_entry, &$pagedata) { + $matches = []; + if(preg_match("/^# ?REDIRECT ?\[\[([^\]]+)\]\]/i", $pagedata, $matches) === 1) + { + //error_log("matches: " . var_export($matches, true)); + // We have found a redirect page! + // Update the metadata to reflect this. + $index_entry->redirect = true; + $index_entry->redirect_target = $matches[1]; + } + else + { + // This page isn't a redirect. Unset the metadata just in case. + if(isset($index_entry->redirect)) + unset($index_entry->redirect); + if(isset($index_entry->redirect_target)) + unset($index_entry->redirect_target); + } + }); + + // Register a help section + add_help_section("25-redirect", "Redirect Pages", "

$settings->sitename supports redirect pages. To create a redirect page, enter something like # REDIRECT [[pagename]] on the first line of the redirect page's content. This must appear as the first line of the page, with no whitespace before it. You can include content beneath the redirect if you want, too (such as a reason for redirecting the page).

"); + } +]); + + + + +register_module([ + "name" => "Search", + "version" => "0.5", + "author" => "Starbeamrainbowlabs", + "description" => "Adds proper search functionality to Pepperminty Wiki using an inverted index to provide a full text search engine. If pages don't show up, then you might have hit a stop word. If not, try requesting the `invindex-rebuild` action to rebuild the inverted index from scratch.", + "id" => "feature-search", + "code" => function() { + global $settings; + + /** + * @api {get} ?action=index&page={pageName} Get an index of words for a given page + * @apiName SearchIndex + * @apiGroup Search + * @apiPermission Anonymous + * + * @apiParam {string} page The page to generate a word index page. + */ + + /* + * ██ ███ ██ ██████ ███████ ██ ██ + * ██ ████ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ █████ ███ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ████ ██████ ███████ ██ ██ + */ + add_action("index", function() { + global $settings, $env; + + $breakable_chars = "\r\n\t .,\\/!\"£$%^&*[]()+`_~#"; + + header("content-type: text/plain"); + + $source = file_get_contents("$env->storage_prefix$env->page.md"); + + $index = search::index($source); + + var_dump($env->page); + var_dump($source); + + var_dump($index); + }); + + /** + * @api {get} ?action=invindex-rebuild Rebuild the inverted search index from scratch + * @apiDescription Causes the inverted search index to be completely rebuilt from scratch. Can take a while for large wikis! + * @apiName SearchInvindexRebuild + * @apiGroup Search + * @apiPermission Anonymous + */ + + /* + * ██ ███ ██ ██ ██ ██ ███ ██ ██████ ███████ ██ ██ + * ██ ████ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ███ █████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ████ ████ ██ ██ ████ ██████ ███████ ██ ██ + * + * ██████ ███████ ██████ ██ ██ ██ ██ ██████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ █████ ██████ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ███████ ██████ ██████ ██ ███████ ██████ + */ + add_action("invindex-rebuild", function() { + search::rebuild_invindex(); + }); + + + /** + * @api {get} ?action=idindex-show Show the id index + * @apiDescription Outputs the id index. Useful if you need to verify that it's working as expected. + * @apiName SearchShowIdIndex + * @apiGroup Search + * @apiPermission Anonymous + */ + add_action("idindex-show", function() { + global $idindex; + header("content-type: application/json; charset=UTF-8"); + exit(json_encode($idindex, JSON_PRETTY_PRINT)); + }); + + /** + * @api {get} ?action=search&query={text} Search the wiki for a given query string + * @apiName Search + * @apiGroup Search + * @apiPermission Anonymous + * + * @apiParam {string} query The query string to search for. + */ + + /* + * ███████ ███████ █████ ██████ ██████ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ███████ █████ ███████ ██████ ██ ███████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ███████ ███████ ██ ██ ██ ██ ██████ ██ ██ + */ + add_action("search", function() { + global $settings, $env, $pageindex, $paths; + + // Create the inverted index if it doesn't exist. + // todo In the future perhaps a CLI for this would be good? + if(!file_exists($paths->searchindex)) + search::rebuild_invindex(); + + if(!isset($_GET["query"])) + exit(page_renderer::render("No Search Terms - Error - $settings->sitename", "

You didn't specify any search terms. Try typing some into the box above.

")); + + $search_start = microtime(true); + + $invindex = search::load_invindex($paths->searchindex); + $results = search::query_invindex($_GET["query"], $invindex); + $resultCount = count($results); + + $env->perfdata->search_time = round((microtime(true) - $search_start)*1000, 3); + + $title = $_GET["query"] . " - Search results - $settings->sitename"; + + $content = "
\n"; + $content .= "

Search Results

"; + + /// Search Box /// + $content .= "
\n"; + $content .= " \n"; + $content .= " \n"; + $content .= "
"; + + $content .= "

Found $resultCount " . ($resultCount === 1 ? "result" : "results") . " in " . $env->perfdata->search_time . "ms. "; + + $query = $_GET["query"]; + if(isset($pageindex->$query)) + { + $content .= "There's a page on $settings->sitename called $query."; + } + else + { + $content .= "There isn't a page called $query on $settings->sitename, but you "; + if((!$settings->anonedits && !$env->is_logged_in) || !$settings->editing) + { + $content .= "do not have permission to create it."; + if(!$env->is_logged_in) + { + $content .= " You could try logging in."; + } + } + else + { + $content .= "can create it."; + } + } + $content .= "

"; + + $i = 0; // todo use $_GET["offset"] and $_GET["result-count"] or something + foreach($results as $result) + { + $link = "?page=" . rawurlencode($result["pagename"]); + $pagesource = file_get_contents($env->storage_prefix . $result["pagename"] . ".md"); + + //echo("Extracting context for result " . $result["pagename"] . ".\n"); + $context = search::extract_context($_GET["query"], $pagesource); + if(strlen($context) === 0) + $context = substr($pagesource, 0, $settings->search_characters_context * 2); + //echo("'Generated search context for " . $result["pagename"] . ": $context'\n"); + $context = search::highlight_context($_GET["query"], $context); + /*if(strlen($context) == 0) + { + $context = search::strip_markup(file_get_contents("$env->page.md", null, null, null, $settings->search_characters_context * 2)); + if($pageindex->{$env->page}->size > $settings->search_characters_context * 2) + $context .= "..."; + }*/ + + // Make redirect pages italics + if(!empty($pageindex->{$result["pagename"]}->redirect)) + $result["pagename"] = "{$result["pagename"]}"; + + // We add 1 to $i here to convert it from an index to a result + // number as people expect it to start from 1 + $content .= "
\n"; + $content .= "

" . $result["pagename"] . "

\n"; + $content .= "

$context

\n"; + $content .= "
\n"; + + $i++; + } + + $content .= "
\n"; + + header("content-type: text/html; charset=UTF-8"); + exit(page_renderer::render($title, $content)); + + //header("content-type: text/plain"); + //var_dump($results); + }); + +/* + * ██████ ██ ██ ███████ ██████ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ █████ ██████ ████ █████ + * ██ ▄▄ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██████ ███████ ██ ██ ██ + * ▀▀ + * ███████ ███████ █████ ██████ ██████ ██ ██ ██ ███ ██ ██████ ███████ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ + * ███████ █████ ███████ ██████ ██ ███████ ██ ██ ██ ██ ██ ██ █████ ███ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ███████ ███████ ██ ██ ██ ██ ██████ ██ ██ ██ ██ ████ ██████ ███████ ██ ██ + */ + + /** + * @api {get} ?action=query-searchindex&query={text} Inspect the internals of the search results for a query + * @apiName Search + * @apiGroup Search + * @apiPermission Anonymous + * + * @apiParam {string} query The query string to search for. + */ + add_action("query-searchindex", function() { + global $env, $paths; + + if(empty($_GET["query"])) { + http_response_code(400); + header("content-type: text/plain"); + exit("Error: No query specified. Specify it with the 'query' GET parameter."); + } + + $env->perfdata->searchindex_decode_start = microtime(true); + $searchIndex = search::load_invindex($paths->searchindex); + $env->perfdata->searchindex_decode_time = (microtime(true) - $env->perfdata->searchindex_decode_start) * 1000; + $env->perfdata->searchindex_query_start = microtime(true); + $searchResults = search::query_invindex($_GET["query"], $searchIndex); + $env->perfdata->searchindex_query_time = (microtime(true) - $env->perfdata->searchindex_query_start) * 1000; + + header("content-type: application/json"); + $result = new stdClass(); + $result->time_format = "ms"; + $result->decode_time = $env->perfdata->searchindex_decode_time; + $result->query_time = $env->perfdata->searchindex_query_time; + $result->total_time = $result->decode_time + $result->query_time; + $result->search_results = $searchResults; + exit(json_encode($result, JSON_PRETTY_PRINT)); + }); + + /* + * ██████ ██████ ███████ ███ ██ ███████ ███████ █████ ██████ ██████ ██ ██ + * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██████ █████ ██ ██ ██ ███████ █████ ███████ ██████ ██ ███████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██ ███████ ██ ████ ███████ ███████ ██ ██ ██ ██ ██████ ██ ██ + */ + add_action("opensearch-description", function () { + global $settings; + $siteRoot = full_url() . "/index.php"; + if(!isset($_GET["debug"])) + header("content-type: application/opensearchdescription+xml"); + else + header("content-type: text/plain"); + + exit(utf8_encode(" + Search $settings->sitename + Search $settings->sitename, which is powered by Pepperminty Wiki. + $settings->sitename Wiki + $settings->favicon + Search content available under the license linked to at the bottom of the search results page. + Starbeamrainbowlabs (https://github.com/sbrl/Pepperminty-Wiki/graphs/contributors) + UTF-8 + UTF-8 + + +")); + }); + + add_action("suggest-pages", function() { + global $settings, $pageindex; + + if($settings->dynamic_page_suggestion_count === 0) + { + header("content-type: application/json"); + header("content-length: 2"); + exit("[]"); + } + + if(empty($_GET["query"])) { + http_response_code(400); + header("content-type: text/plain"); + exit("Error: You didn't specify the 'query' GET parameter."); + } + + // Rank each page name + $results = []; + foreach($pageindex as $pageName => $entry) { + $results[] = [ + "pagename" => $pageName, + // Costs: Insert: 1, Replace: 8, Delete: 6 + "distance" => levenshtein($_GET["query"], $pageName, 1, 8, 6) + ]; + } + + // Sort the page names by distance form the original query + usort($results, function($a, $b) { + if($a["distance"] == $b["distance"]) + return strcmp($a["pagename"], $b["pagename"]); + return $a["distance"] < $b["distance"] ? -1 : 1; + }); + + // Send the results to the user + header("content-type: application/json"); + exit(json_encode(array_slice($results, 0, $settings->dynamic_page_suggestion_count))); + }); + + if($settings->dynamic_page_suggestion_count > 0) + { + page_renderer::AddJSSnippet('/// Dynamic page suggestion system +// Micro snippet 8 - Promisified GET (fetched 20th Nov 2016) +function get(u){return new Promise(function(r,t,a){a=new XMLHttpRequest();a.onload=function(b,c){b=a.status;c=a.response;if(b>199&&b<300){r(c)}else{t(c)}};a.open("GET",u,true);a.send(null)})} + +window.addEventListener("load", function(event) { + var searchBox = document.querySelector("input[type=search]"); + searchBox.dataset.lastValue = ""; + searchBox.addEventListener("keyup", function(event) { + // Make sure that we don\'t keep sending requests to the server if nothing has changed + if(searchBox.dataset.lastValue == event.target.value) + return; + searchBox.dataset.lastValue = event.target.value; + // Fetch the suggestions from the server + get("?action=suggest-pages&query=" + encodeURIComponent(event.target.value)).then(function(response) { + var suggestions = JSON.parse(response), + dataList = document.getElementById("allpages"); + + // If the server sent no suggestions, then we shouldn\'t replace the contents of the datalist + if(suggestions.length == 0) + return; + + console.info(`Fetched suggestions for ${event.target.value}:`, suggestions.map(s => s.pagename)); + + // Remove all the existing suggestions + while(dataList.firstChild) { + dataList.removeChild(dataList.firstChild); + } + + // Add the new suggestions to the datalist + var optionsFrag = document.createDocumentFragment(); + suggestions.forEach(function(suggestion) { + var suggestionElement = document.createElement("option"); + suggestionElement.value = suggestion.pagename; + suggestionElement.dataset.distance = suggestion.distance; + optionsFrag.appendChild(suggestionElement); + }); + dataList.appendChild(optionsFrag); + }); + }); +}); +'); + } + } +]); + +class search +{ + // Words that we should exclude from the inverted index + public static $stop_words = [ + "a", "about", "above", "above", "across", "after", "afterwards", "again", + "against", "all", "almost", "alone", "along", "already", "also", + "although", "always", "am", "among", "amongst", "amoungst", "amount", + "an", "and", "another", "any", "anyhow", "anyone", "anything", "anyway", + "anywhere", "are", "around", "as", "at", "back", "be", "became", + "because", "become", "becomes", "becoming", "been", "before", + "beforehand", "behind", "being", "below", "beside", "besides", + "between", "beyond", "bill", "both", "bottom", "but", "by", "call", + "can", "cannot", "cant", "co", "con", "could", "couldnt", "cry", "de", + "describe", "detail", "do", "done", "down", "due", "during", "each", + "eg", "eight", "either", "eleven", "else", "elsewhere", "empty", + "enough", "etc", "even", "ever", "every", "everyone", "everything", + "everywhere", "except", "few", "fill", "find", + "fire", "first", "five", "for", "former", "formerly", "found", + "four", "from", "front", "full", "further", "get", "give", "go", "had", + "has", "hasnt", "have", "he", "hence", "her", "here", "hereafter", + "hereby", "herein", "hereupon", "hers", "herself", "him", "himself", + "his", "how", "however", "ie", "if", "in", "inc", "indeed", + "interest", "into", "is", "it", "its", "itself", "keep", "last", + "latter", "latterly", "least", "less", "ltd", "made", "many", "may", + "me", "meanwhile", "might", "mine", "more", "moreover", "most", + "mostly", "move", "much", "must", "my", "myself", "name", "namely", + "neither", "never", "nevertheless", "next", "nine", "no", "none", + "nor", "not", "nothing", "now", "nowhere", "of", "off", "often", "on", + "once", "one", "only", "onto", "or", "other", "others", "otherwise", + "our", "ours", "ourselves", "out", "over", "own", "part", "per", + "perhaps", "please", "put", "rather", "re", "same", "see", "seem", + "seemed", "seeming", "seems", "serious", "several", "she", "should", + "show", "side", "since", "sincere", "six", "sixty", "so", "some", + "somehow", "someone", "something", "sometime", "sometimes", + "somewhere", "still", "such", "system", "take", "ten", "than", "that", + "the", "their", "them", "themselves", "then", "thence", "there", + "thereafter", "thereby", "therefore", "therein", "thereupon", "these", + "they", "thickv", "thin", "third", "this", "those", "though", "three", + "through", "throughout", "thru", "thus", "to", "together", "too", "top", + "toward", "towards", "twelve", "twenty", "two", "un", "under", "until", + "up", "upon", "us", "very", "via", "was", "we", "well", "were", "what", + "whatever", "when", "whence", "whenever", "where", "whereafter", + "whereas", "whereby", "wherein", "whereupon", "wherever", "whether", + "which", "while", "whither", "who", "whoever", "whole", "whom", "whose", + "why", "will", "with", "within", "without", "would", "yet", "you", + "your", "yours", "yourself", "yourselves" + ]; + + public static function index($source) + { + $source = html_entity_decode($source, ENT_QUOTES); + $source_length = strlen($source); + + $index = []; + + $terms = self::tokenize($source); + $i = 0; + foreach($terms as $term) + { + $nterm = $term; + + // Skip over stop words (see https://en.wikipedia.org/wiki/Stop_words) + if(in_array($nterm, self::$stop_words)) continue; + + if(!isset($index[$nterm])) + { + $index[$nterm] = [ "freq" => 0, "offsets" => [] ]; + } + + $index[$nterm]["freq"]++; + $index[$nterm]["offsets"][] = $i; + + $i++; + } + + return $index; + } + + public static function tokenize($source) + { + $source = strtolower($source); + $source = str_replace([ '[', ']', '|', '{', '}' ], " ", $source); + return preg_split("/((^\p{P}+)|(\p{P}*\s+\p{P}*)|(\p{P}+$))|\|/u", $source, -1, PREG_SPLIT_NO_EMPTY); + } + + public static function strip_markup($source) + { + return str_replace([ "[", "]", "\"", "*", "_", " - ", "`" ], "", $source); + } + + public static function rebuild_invindex() + { + global $pageindex, $env, $paths; + + header("content-type: text/event-stream"); + + // Clear the id index out + ids::clear(); + + // Reindex each page in turn + $invindex = []; + foreach($pageindex as $pagename => $pagedetails) + { + $pagesource = utf8_encode(file_get_contents("$env->storage_prefix$pagename.md")); + $index = self::index($pagesource); + + $pageid = ids::getid($pagename); + self::merge_into_invindex($invindex, $pageid, $index); + + echo("Added $pagename (id #$pageid) to the new search index.\n\n"); + flush(); + } + + echo("Search index rebuilding complete.\n\n"); + echo("Saving new search index to '$paths->searchindex'.\n\n"); + + self::save_invindex($paths->searchindex, $invindex); + } + + /* + * @summary Sorts an index alphabetically. Will also sort an inverted index. + * This allows us to do a binary search instead of a regular + * sequential search. + */ + public static function sort_index(&$index) + { + ksort($index, SORT_NATURAL); + } + + /* + * @summary Compares two *regular* indexes to find the differences between them. + * + * @param {array} $indexa - The old index. + * @param {array} $indexb - The new index. + * @param {array} $changed - An array to be filled with the nterms of all + * the changed entries. + * @param {array} $removed - An array to be filled with the nterms of all + * the removed entries. + */ + public static function compare_indexes($oldindex, $newindex, &$changed, &$removed) + { + foreach($oldindex as $nterm => $entry) + { + if(!isset($newindex[$nterm])) + $removed[] = $nterm; + } + foreach($newindex as $nterm => $entry) + { + if(!isset($oldindex[$nterm]) or // If this world is new + $newindex[$nterm] !== $oldindex[$nterm]) // If this word has changed + $changed[$nterm] = $newindex[$nterm]; + } + } + + /* + * @summary Reads in and parses an inverted index. + */ + // Todo remove this function and make everything streamable + public static function load_invindex($invindex_filename) { + $invindex = json_decode(file_get_contents($invindex_filename), true); + return $invindex; + } + + public static function measure_invindex_load_time($invindex_filename) { + global $env; + + $searchindex_decode_start = microtime(true); + search::load_invindex($invindex_filename); + $env->perfdata->searchindex_decode_time = round((microtime(true) - $searchindex_decode_start)*1000, 3); + } + + /* + * @summary Merge an index into an inverted index. + */ + public static function merge_into_invindex(&$invindex, $pageid, &$index, &$removals = []) + { + // Remove all the subentries that were removed since last time + foreach($removals as $nterm) + { + unset($invindex[$nterm][$pageid]); + } + + // Merge all the new / changed index entries into the inverted index + foreach($index as $nterm => $newentry) + { + // If the nterm isn't in the inverted index, then create a space for it + if(!isset($invindex[$nterm])) $invindex[$nterm] = []; + $invindex[$nterm][$pageid] = $newentry; + + // Sort the page entries for this word by frequency + uasort($invindex[$nterm], function($a, $b) { + if($a["freq"] == $b["freq"]) return 0; + return ($a["freq"] < $b["freq"]) ? +1 : -1; + }); + } + + // Sort the inverted index by rank + uasort($invindex, function($a, $b) { + $ac = count($a); $bc = count($b); + if($ac == $bc) return 0; + return ($ac < $bc) ? +1 : -1; + }); + } + + /** + * Deletes the given pageid from the given pageindex. + * @param inverted_index &$invindex The inverted index. + * @param number $pageid The pageid to remove. + */ + public static function delete_entry(&$invindex, $pageid) + { + $str_pageid = (string)$pageid; + foreach($invindex as $nterm => &$entry) + { + if(isset($entry[$pageid])) + unset($entry[$pageid]); + if(isset($entry[$str_pageid])) + unset($entry[$str_pageid]); + if(count($entry) === 0) + unset($invindex[$nterm]); + } + } + + public static function save_invindex($filename, &$invindex) + { + file_put_contents($filename, json_encode($invindex)); + } + + public static function query_invindex($query, &$invindex) + { + global $settings, $pageindex; + + $query_terms = self::tokenize($query); + $matching_pages = []; + + + // Loop over each term in the query and find the matching page entries + $count = count($query_terms); + for($i = 0; $i < $count; $i++) + { + $qterm = $query_terms[$i]; + + // Stop words aren't worth the bother - make sure we don't search + // the title or the tags for them + if(in_array($qterm, self::$stop_words)) + continue; + + // Only search the inverted index if it actually exists there + if(isset($invindex[$qterm])) + { + // Loop over each page in the inverted index entry + foreach($invindex[$qterm] as $pageid => $page_entry) + { + // Create an entry in the matching pages array if it doesn't exist + if(!isset($matching_pages[$pageid])) + $matching_pages[$pageid] = [ "nterms" => [] ]; + $matching_pages[$pageid]["nterms"][$qterm] = $page_entry; + } + } + + + // Loop over the pageindex and search the titles / tags + foreach ($pageindex as $pagename => $pagedata) + { + // Get the current page's id + $pageid = ids::getid($pagename); + // Consider matches in the page title + if(stripos($pagename, $qterm) !== false) + { + // We found the qterm in the title + if(!isset($matching_pages[$pageid])) + $matching_pages[$pageid] = [ "nterms" => [] ]; + + // Set up a counter for page title matches if it doesn't exist already + if(!isset($matching_pages[$pageid]["title-matches"])) + $matching_pages[$pageid]["title-matches"] = 0; + + $matching_pages[$pageid]["title-matches"] += count(mb_stripos_all($pagename, $qterm)); + } + + // Consider matches in the page's tags + if(isset($pagedata->tags) and // If this page has tags + stripos(implode(" ", $pagedata->tags), $qterm) !== false) // And we found the qterm in the tags + { + if(!isset($matching_pages[$pageid])) + $matching_pages[$pageid] = [ "nterms" => [] ]; + + // Set up a counter for tag match if there isn't one already + if(!isset($matching_pages[$pageid]["tag-matches"])) + $matching_pages[$pageid]["tag-matches"] = 0; + $matching_pages[$pageid]["tag-matches"] += count(mb_stripos_all(implode(" ", $pagedata->tags), $qterm)); + } + } + } + + + foreach($matching_pages as $pageid => &$pagedata) + { + $pagedata["pagename"] = ids::getpagename($pageid); + $pagedata["rank"] = 0; + + $pageOffsets = []; + + // Loop over each search term found on this page + foreach($pagedata["nterms"] as $pterm => $entry) + { + // Add the number of occurrences of this search term to the ranking + $pagedata["rank"] += $entry["freq"]; + + // Add the offsets to a listof all offsets on this page + foreach($entry["offsets"] as $offset) + $pageOffsets[] = $offset; + } + /* + // Sort the list of offsets + $pageOffsets = array_unique($pageOffsets); + sort($pageOffsets); + var_dump($pageOffsets); + + // Calcualate the clump distances via a variable moving window size + $pageOffsetsCount = count($pageOffsets); + $clumpDistanceWindow = min($count, $pageOffsetsCount); // a.k.a. count($query_terms) - see above + $clumpDistances = []; + for($i = 0; $i < $pageOffsetsCount - $clumpDistanceWindow; $i++) + $clumpDistances[] = $pageOffsets[$i] - $pageOffsets[$i + $clumpDistanceWindow]; + + // Sort the new list of clump distances + sort($clumpDistances); + // Calcualate a measureof how clumped the offsets are + $tightClumpLimit = floor((count($clumpDistances) - 1) / 0.25); + $tightClumpsMeasure = $clumpDistances[$tightClumpLimit] - $clumpDistances[0]; + $clumpsRange = $clumpDistances[count($clumpDistances) - 1] - $clumpDistances[0]; + + $clumpiness = $tightClumpsMeasure / $clumpsRange; + echo("{$pagedata["pagename"]} - $clumpiness"); + */ + + // Consider matches in the title / tags + if(isset($pagedata["title-matches"])) + $pagedata["rank"] += $pagedata["title-matches"] * $settings->search_title_matches_weighting; + if(isset($pagedata["tag-matches"])) + $pagedata["rank"] += $pagedata["tag-matches"] * $settings->search_tags_matches_weighting; + + // todo remove items if the rank is below a threshold + } + + // todo sort by rank here + uasort($matching_pages, function($a, $b) { + if($a["rank"] == $b["rank"]) return 0; + return ($a["rank"] < $b["rank"]) ? +1 : -1; + }); + + return $matching_pages; + } + + public static function extract_context($query, $source) + { + global $settings; + + $nterms = self::tokenize($query); + $matches = []; + // Loop over each nterm and find it in the source + foreach($nterms as $nterm) + { + if(in_array($nterm, static::$stop_words)) + continue; + $all_offsets = mb_stripos_all($source, $nterm); + // Skip over adding matches if there aren't any + if($all_offsets === false) + continue; + foreach($all_offsets as $offset) + { + $matches[] = [ $nterm, $offset ]; + } + } + + // Sort the matches by offset + usort($matches, function($a, $b) { + if($a[1] == $b[1]) return 0; + return ($a[1] > $b[1]) ? +1 : -1; + }); + + $sourceLength = strlen($source); + + $contexts = []; + $basepos = 0; + $matches_count = count($matches); + while($basepos < $matches_count) + { + // Store the next match along - all others will be relative to that one + $group = [$matches[$basepos]]; + + // Start scanning at the next one along - we always store the first match + $scanpos = $basepos + 1; + $distance = 0; + + while(true) + { + // Break out if we reach the end + if($scanpos >= $matches_count) break; + + // Find the distance between the current one and the last one + $distance = $matches[$scanpos][1] - $matches[$scanpos - 1][1]; + + // Store it if the distance is below the threshold + if($distance < $settings->search_characters_context) + $group[] = $matches[$scanpos]; + else + break; + + $scanpos++; + } + + $context_start = $group[0][1] - $settings->search_characters_context; + $context_end = $group[count($group) - 1][1] + $settings->search_characters_context; + + if($context_start < 0) $context_start = 0; + if($context_end > $sourceLength) $context_end = $sourceLength; + + //echo("Got context. Start: $context_start, End: $context_end\n"); + //echo("Group:"); var_dump($group); + + $context = substr($source, $context_start, $context_end - $context_start); + + // Strip the markdown from the context - it's most likely going to + // be broken anyway. + //$context = self::strip_markup($context); + + // Escape special characters to protect against attacks + $context = htmlentities($context); + + $contexts[] = $context; + + $basepos = $scanpos + 1; + } + + return implode(" ... ", $contexts); + } + + public static function highlight_context($query, $context) + { + $qterms = self::tokenize($query); + + foreach($qterms as $qterm) + { + if(in_array($qterm, static::$stop_words)) + continue; + // From http://stackoverflow.com/a/2483859/1460422 + $context = preg_replace("/" . str_replace("/", "\/", preg_quote($qterm)) . "/i", "$0", $context); + } + + return $context; + } +} + + + + +register_module([ + "name" => "Uploader", + "version" => "0.5.8", + "author" => "Starbeamrainbowlabs", + "description" => "Adds the ability to upload files to Pepperminty Wiki. Uploaded files act as pages and have the special 'File/' prefix.", + "id" => "feature-upload", + "code" => function() { + global $settings; + /** + * @api {get} ?action=upload Get a page to let you upload a file. + * @apiName UploadFilePage + * @apiGroup Upload + * @apiPermission User + */ + + /** + * @api {post} ?action=upload Upload a file + * @apiName UploadFile + * @apiGroup Upload + * @apiPermission User + * + * @apiParam {string} name The name of the file to upload. + * @apiParam {string} description A description of the file. + * @apiParam {file} file The file to upload. + * + * @apiUse UserNotLoggedInError + * @apiError UploadsDisabledError Uploads are currently disabled in the wiki's settings. + * @apiError UnknownFileTypeError The type of the file you uploaded is not currently allowed in the wiki's settings. + * @apiError ImageDimensionsFiledError PeppermintyWiki couldn't obtain the dimensions of the image you uploaded. + * @apiError DangerousFileError The file uploaded appears to be dangerous. + * @apiError DuplicateFileError The filename specified is a duplicate of a file that already exists. + * @apiError FileTamperedError Pepperminty Wiki couldn't verify that the file wasn't tampered with during theupload process. + */ + + /* + * ██ ██ ██████ ██ ██████ █████ ██████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██████ ██ ██ ██ ███████ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██ ███████ ██████ ██ ██ ██████ + */ + add_action("upload", function() { + global $settings, $env, $pageindex, $paths; + + switch($_SERVER["REQUEST_METHOD"]) + { + case "GET": + // Send upload page + + if(!$settings->upload_enabled) + exit(page_renderer::render("Upload Disabled - $setting->sitename", "

You can't upload anything at the moment because $settings->sitename has uploads disabled. Try contacting $settings->admindetails_name, your site Administrator. Go back.

")); + if(!$env->is_logged_in) + exit(page_renderer::render("Upload Error - $settings->sitename", "

You are not currently logged in, so you can't upload anything.

+

Try logging in first.

")); + + exit(page_renderer::render("Upload - $settings->sitename", "

Upload file

+

Select an image below, and then type a name for it in the box. This server currently supports uploads up to " . human_filesize(get_max_upload_size()) . " in size.

+

$settings->sitename currently supports uploading of the following file types: " . implode(", ", $settings->upload_allowed_file_types) . ".

+
+ + +
+ + +
+ + +

$settings->editing_message

+ +
+ ")); + + break; + + case "POST": + // Recieve file + + // Make sure uploads are enabled + if(!$settings->upload_enabled) + { + 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) + { + 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.")); + } + + // Calculate the target name, removing any characters we + // are unsure about. + $target_name = makepathsafe($_POST["name"]); + $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 + switch(substr($mime_type, 0, strpos($mime_type, "/"))) + { + case "image": + $extra_data = []; + // Check SVG uploads with a special function + $imagesize = $mime_type !== "image/svg+xml" ? getimagesize($temp_filename, $extra_data) : upload_check_svg($temp_filename); + + // Make sure that the image size is defined + if(!is_int($imagesize[0]) or !is_int($imagesize[1])) + { + http_response_code(415); + exit(page_renderer::render("Upload Error - $settings->sitename", "

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" ])) + { + 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.

")); + } + + $new_filename = "$paths->upload_file_prefix$target_name.$file_extension"; + $new_description_filename = "$new_filename.md"; + + if(isset($pageindex->$new_filename)) + exit(page_renderer::render("Upload Error - $settings->sitename", "

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.

")); + + if(!file_exists($env->storage_prefix . "Files")) + mkdir($env->storage_prefix . "Files", 0775); + + 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 $settings->sitename has been attacked. Please contact " . $settings->admindetails_name . ", your $settings->sitename Administrator.

")); + } + + $description = $_POST["description"]; + + // 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); + $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_filename = $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_filename, + "user" => $env->user, + "filesize" => filesize($env->storage_prefix . $entry->uploadedfilepath) + ]); + } + + header("location: ?action=view&page=$new_filename&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 = $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\""); + + // If the size is set or 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"; + + $preview_sizes = [ 256, 512, 768, 1024, 1440 ]; + $preview_html .= "\t\t\t
+ + \n\t\t\t
"; + break; + + case "video": + $preview_html .= "\t\t\t
+ +
"; + break; + + case "audio": + $preview_html .= "\t\t\t
+ +
"; + } + + $fileInfo = []; + $fileInfo["Name"] = str_replace("File/", "", $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; + + $preview_html .= "\t\t\t

File Information

+ "; + foreach ($fileInfo as $displayName => $displayValue) + { + $preview_html .= "\n"; + } + $preview_html .= "
$displayName$displayValue
"; + + $parts["{content}"] = str_replace("", "\n$preview_html", $parts["{content}"]); + } + }); + + // Register a section on the help page on uploading files + add_help_section("28-uploading-files", "Uploading Files", "

$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.

"); + } +]); + +//// Pair of functions to calculate the actual maximum upload size supported by the server +//// Lifted from Drupal by @meustrus from Stackoverflow. Link to answer: +//// http://stackoverflow.com/a/25370978/1460422 +// Returns a file size limit in bytes based on the PHP upload_max_filesize +// and post_max_size +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; +} + +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); + } +} + +function upload_check_svg($temp_filename) +{ + global $settings; + // Check for script tags + if(strpos(file_get_contents($temp_filename), "sitename", "

$settings->sitename detected that you uploaded an SVG image and performed some extra security checks on your file. Whilst performing these checks it was discovered that the file you uploaded contains some Javascript, which could be dangerous. 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.

")); + } + + // Find and return the size of the SVG image + return getsvgsize($temp_filename); +} + +function getsvgsize($svgFilename) +{ + $svg = simplexml_load_file($svgFilename); // Load it as XML + if($svg === false) + { + http_response_code(415); + exit(page_renderer::render("Upload Error - $settings->sitename", "

When $settings->sitename tried to open your SVG file for checking, it found some invalid syntax. The uploaded file has been discarded. Go back to try again.

")); + } + $rootAttrs = $svg->attributes(); + $imageSize = false; + if(isset($rootAttrs->width) and isset($rootAttrs->height)) + $imageSize = [ intval($rootAttrs->width), intval($rootAttrs->height) ]; + else if(isset($rootAttrs->viewBox)) + $imageSize = array_map("intval", array_slice(explode(" ", $rootAttrs->viewBox), -2, 2)); + + return $imageSize; +} + +function errorimage($text, $target_size = null) +{ + $width = 640; + $height = 480; + + if(!empty($target_size)) + { + $width = $target_size; + $height = $target_size * (2 / 3); + } + + $image = imagecreatetruecolor($width, $height); + imagefill($image, 0, 0, imagecolorallocate($image, 238, 232, 242)); // Set the background to #eee8f2 + $fontwidth = imagefontwidth(3); + imagestring($image, 3, + ($width / 2) - (($fontwidth * strlen($text)) / 2), + ($height / 2) - (imagefontheight(3) / 2), + $text, + imagecolorallocate($image, 17, 17, 17) // #111111 + ); + + return $image; +} + + + + +register_module([ + "name" => "User Preferences", + "version" => "0.2.1", + "author" => "Starbeamrainbowlabs", + "description" => "Adds a user preferences page, letting pople do things like change their email address and password.", + "id" => "feature-user-preferences", + "code" => function() { + global $env, $settings; + /** + * @api {get} ?action=user-preferences Get a user preferences configuration page. + * @apiName UserPreferences + * @apiGroup Settings + * @apiPermission User + */ + + /* + * ██ ██ ███████ ███████ ██████ + * ██ ██ ██ ██ ██ ██ + * ██ ██ ███████ █████ ██████ █████ + * ██ ██ ██ ██ ██ ██ + * ██████ ███████ ███████ ██ ██ + * + * ██████ ██████ ███████ ███████ ███████ + * ██ ██ ██ ██ ██ ██ ██ + * ██████ ██████ █████ █████ ███████ + * ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ███████ ██ ███████ + */ + add_action("user-preferences", function() { + global $env, $settings; + + if(!$env->is_logged_in) + { + exit(page_renderer::render_main("Error - $settings->sitename", "

Since you aren't logged in, you can't change your preferences. This is because stored preferences are tied to each registered user account. You can login here.

")); + } + + $statusMessages = [ + "change-password" => "Password changed successfully!" + ]; + + if(!isset($env->user_data->emailAddress)) { + $env->user_data->emailAddress = ""; + save_userdata(); + } + + $content = "

User Preferences

\n"; + if(isset($_GET["success"]) && $_GET["success"] === "yes") + { + $content .= "

" . $statusMessages[$_GET["operation"]] . "

\n"; + } + $content .= "\n"; + $content .= "\n"; + $content .= "
\n"; + $content .= " \n"; + $content .= " \n"; + $content .= "

Used to send you notifications etc. Never shared with anyone except $settings->admindetails_name, $settings->sitename's administrator.

\n"; + $content .= " \n"; + $content .= "
\n"; + $content .= "

Change Password"; + $content .= "
\n"; + $content .= " \n"; + $content .= " \n"; + $content .= "
\n"; + $content .= " \n"; + $content .= " \n"; + $content .= "
\n"; + $content .= " \n"; + $content .= " \n"; + $content .= "
\n"; + $content .= " \n"; + $content .= "
\n"; + + if($env->is_admin) + $content .= "

As an admin, you can also edit $settings->sitename's master settings.

\n"; + + exit(page_renderer::render_main("User Preferences - $settings->sitename", $content)); + }); + + add_action("save-preferences", function() { + global $env, $settings; + + if(!$env->is_logged_in) + { + http_response_code(400); + exit(page_renderer::render_main("Error Saving Preferences - $settings->sitename", "

You aren't logged in, so you can't save your preferences. Try logging in first.

")); + } + + if(isset($_POST["email-address"])) + { + if(mb_strlen($_POST["email-address"]) > 320) + { + http_response_code(413); + exit(page_renderer::render_main("Error Saving Email Address - $settings->sitename", "

The email address you supplied ({$_POST['email-address']}) is too long. Email addresses can only be 320 characters long. Go back.")); + } + + if(mb_strpos($_POST["email-address"], "@") === false) + { + http_response_code(422); + exit(page_renderer::render_main("Error Saving Email Address - $settings->sitename", "

The email address you supplied ({$_POST['email-address']}) doesn't appear to be valid. Go back.")); + } + + $env->user_data->emailAddress = $_POST["email-address"]; + } + + // Save the user's preferences + if(!save_userdata()) + { + http_response_code(503); + exit(page_renderer::render_main("Error Saving Preferences - $settings->sitename", "

$settings->sitename had some trouble saving your preferences! Please contact $settings->admindetails_name, $settings->sitename's administrator and tell them about this error if it still occurs in 5 minutes. They can be contacted by email at this address: " . hide_email($settings->admindetails_email) . ".

")); + } + + exit(page_renderer::render_main("Preferences Saved Successfully - $settings->sitename", "

Your preferences have been saved successfully! You could go back your preferences page, or on to the $settings->defaultpage.

")); + }); + + /** + * @api {post} ?action=change-password Change your password + * @apiName ChangePassword + * @apiGroup Settings + * @apiPermission User + * + * @apiParam {string} current-pass Your current password. + * @apiParam {string} new-pass Your new password. + * @apiParam {string} new-pass-confirm Your new password again, to make sure you've typed it correctly. + * + * @apiError PasswordMismatchError The new password fields don't match. + */ + add_action("change-password", function() { + global $env, $settings; + + // Make sure the new password was typed correctly + // This comes before the current password check since that's more intensive + if($_POST["new-pass"] !== $_POST["new-pass-confirm"]) { + exit(page_renderer::render_main("Password mismatch - $settings->sitename", "

The new password you typed twice didn't match! Go back.

")); + } + // Check the current password + if(hash_password($_POST["current-pass"]) !== $env->user_data->password) { + exit(page_renderer::render_main("Password mismatch - $settings->sitename", "

Error: You typed your current password incorrectly! Go back.

")); + } + + // All's good! Go ahead and change the password. + $env->user_data->password = hash_password($_POST["new-pass"]); + // Save the userdata back to disk + save_userdata(); + + http_response_code(307); + header("location: ?action=user-preferences&success=yes&operation=change-password"); + exit(page_renderer::render_main("Password Changed Successfully", "

You password was changed successfully. Go back to the user preferences page.

")); + }); + + // Display a help section on the user preferences, but only if the user + // is logged in and so able to access them + if($env->is_logged_in) + { + add_help_section("910-user-preferences", "User Preferences", "

As you are logged in, $settings->sitename lets you configure a selection of personal preferences. These can be viewed and tweaked to you liking over on the preferences page, which can be accessed at any time by clicking the cog icon (it looks something like this: $settings->user_preferences_button_text), though the administrator of $settings->sitename ($settings->admindetails_name) may have changed its appearance.

"); + } + } +]); + + + register_module([ "name" => "Credits", @@ -4648,84 +4651,84 @@ register_module([ ]); - - -register_module([ - "name" => "Debug Information", - "version" => "0.1.1", - "author" => "Starbeamrainbowlabs", - "description" => "Adds a debug action for administrator use only that collects a load of useful information to make reporting bugs easier.", - "id" => "page-debug-info", - "code" => function() { - global $settings, $env; - /** - * @api {get} ?action=debug Get a debug dump - * @apiName Debug - * @apiGroup Utility - * @apiPermission Moderator - * - * @apiUse UserNotModeratorError - */ - - /* - * ██████ ███████ ██████ ██ ██ ██████ - * ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ █████ ██████ ██ ██ ██ ███ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ███████ ██████ ██████ ██████ - */ - add_action("debug", function() { - global $settings, $env, $paths, $version; - header("content-type: text/plain"); - - if(!$env->is_admin) - { - exit("You must be logged in as an moderator in order to generate debugging information."); - } - - $title = "$settings->sitename debug report"; - echo("$title\n"); - echo(str_repeat("=", strlen($title)) . "\n"); - echo("Powered by Pepperminty Wiki version $version.\n"); - echo("This report may contain personal information.\n\n"); - echo("Environment: "); - echo(var_export($env, true)); - echo("\nPaths: "); - var_dump(var_export($paths, true)); - echo("\nServer information:\n"); - echo("uname -a: " . php_uname() . "\n"); - echo("Path: " . getenv("PATH") . "\n"); - echo("Temporary directory: " . sys_get_temp_dir() . "\n"); - echo("Server: " . $_SERVER["SERVER_SOFTWARE"] . "\n"); - echo("Web root: " . $_SERVER["DOCUMENT_ROOT"] . "\n"); - echo("Web server user: " . exec("whoami") . "\n"); - echo("PHP version: " . phpversion() . "\n"); - echo("index.php location: " . __FILE__ . "\n"); - echo("index.php file permissions: " . substr(sprintf('%o', fileperms("./index.php")), -4) . "\n"); - echo("Current folder permissions: " . substr(sprintf('%o', fileperms(".")), -4) . "\n"); - echo("Storage directory permissions: " . substr(sprintf('%o', fileperms($env->storage_prefix)), -4) . "\n"); - echo("Loaded extensions: " . implode(", ", get_loaded_extensions()) . "\n"); - echo("Settings:\n-----\n"); - $settings_export = explode("\n", var_export($settings, true)); - foreach ($settings_export as &$row) - { - if(preg_match("/(sitesecret|email)/i", $row)) $row = "********* secret *********"; - } - echo(implode("\n", $settings_export)); - echo("\n-----\n"); - exit(); - }); - - if($env->is_admin) - { - add_help_section("950-debug-information", "Gathering debug information", "

As a moderator, $settings->sitename gives you the ability to generate a report on $settings->sitename's installation of Pepperminty Wiki for debugging purposes.

-

To generate such a report, visit the debug action or click here.

"); - } - } -]); - - - + + +register_module([ + "name" => "Debug Information", + "version" => "0.1.1", + "author" => "Starbeamrainbowlabs", + "description" => "Adds a debug action for administrator use only that collects a load of useful information to make reporting bugs easier.", + "id" => "page-debug-info", + "code" => function() { + global $settings, $env; + /** + * @api {get} ?action=debug Get a debug dump + * @apiName Debug + * @apiGroup Utility + * @apiPermission Moderator + * + * @apiUse UserNotModeratorError + */ + + /* + * ██████ ███████ ██████ ██ ██ ██████ + * ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ █████ ██████ ██ ██ ██ ███ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ███████ ██████ ██████ ██████ + */ + add_action("debug", function() { + global $settings, $env, $paths, $version; + header("content-type: text/plain"); + + if(!$env->is_admin) + { + exit("You must be logged in as an moderator in order to generate debugging information."); + } + + $title = "$settings->sitename debug report"; + echo("$title\n"); + echo(str_repeat("=", strlen($title)) . "\n"); + echo("Powered by Pepperminty Wiki version $version.\n"); + echo("This report may contain personal information.\n\n"); + echo("Environment: "); + echo(var_export($env, true)); + echo("\nPaths: "); + var_dump(var_export($paths, true)); + echo("\nServer information:\n"); + echo("uname -a: " . php_uname() . "\n"); + echo("Path: " . getenv("PATH") . "\n"); + echo("Temporary directory: " . sys_get_temp_dir() . "\n"); + echo("Server: " . $_SERVER["SERVER_SOFTWARE"] . "\n"); + echo("Web root: " . $_SERVER["DOCUMENT_ROOT"] . "\n"); + echo("Web server user: " . exec("whoami") . "\n"); + echo("PHP version: " . phpversion() . "\n"); + echo("index.php location: " . __FILE__ . "\n"); + echo("index.php file permissions: " . substr(sprintf('%o', fileperms("./index.php")), -4) . "\n"); + echo("Current folder permissions: " . substr(sprintf('%o', fileperms(".")), -4) . "\n"); + echo("Storage directory permissions: " . substr(sprintf('%o', fileperms($env->storage_prefix)), -4) . "\n"); + echo("Loaded extensions: " . implode(", ", get_loaded_extensions()) . "\n"); + echo("Settings:\n-----\n"); + $settings_export = explode("\n", var_export($settings, true)); + foreach ($settings_export as &$row) + { + if(preg_match("/(sitesecret|email)/i", $row)) $row = "********* secret *********"; + } + echo(implode("\n", $settings_export)); + echo("\n-----\n"); + exit(); + }); + + if($env->is_admin) + { + add_help_section("950-debug-information", "Gathering debug information", "

As a moderator, $settings->sitename gives you the ability to generate a report on $settings->sitename's installation of Pepperminty Wiki for debugging purposes.

+

To generate such a report, visit the debug action or click here.

"); + } + } +]); + + + register_module([ "name" => "Page deleter", @@ -4837,7 +4840,7 @@ register_module([ ]); - + register_module([ "name" => "Page editor", @@ -4928,7 +4931,7 @@ register_module([ exit(page_renderer::render_main("Viewing source for $env->page", $sourceViewContent)); } else - { + { $errorMessage = "

The page $env->page does not exist, but you do not have permission to create it.

If you haven't already, perhaps you should try logging in.

\n"; if($isOtherUsersPage) { @@ -5232,7 +5235,7 @@ DIFFSCRIPT; ]); - + register_module([ "name" => "Export", @@ -5309,7 +5312,7 @@ register_module([ ]); - + register_module([ "name" => "Help page", @@ -5463,7 +5466,7 @@ register_module([ ]); - + register_module([ "name" => "Page list", @@ -5611,7 +5614,7 @@ function generate_page_list($pagelist) } - + register_module([ "name" => "Login", @@ -5771,7 +5774,7 @@ function hash_password($pass) } - + register_module([ "name" => "Logout", @@ -5812,7 +5815,7 @@ register_module([ ]); - + register_module([ "name" => "Page mover", @@ -5944,7 +5947,7 @@ register_module([ ]); - + register_module([ "name" => "Update", @@ -6033,56 +6036,56 @@ register_module([ } ]); - - -register_module([ - "name" => "User list", - "version" => "0.1", - "author" => "Starbeamrainbowlabs", - "description" => "Adds a 'user-list' action that generates a list of users. Supports json output with 'format=json' in the queyr string.", - "id" => "page-user-list", - "code" => function() { - global $settings; - /** - * @api {get} ?action=user-list[format=json] List all users - * @apiName UserList - * @apiGroup Utility - * @apiPermission Anonymous - */ - - /* - * ██ ██ ███████ ███████ ██████ ██ ██ ███████ ████████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ███████ █████ ██████ █████ ██ ██ ███████ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ███████ ███████ ██ ██ ███████ ██ ███████ ██ - */ - add_action("user-list", function() { - global $env, $settings; - - $userList = array_keys(get_object_vars($settings->users)); - if(!empty($_GET["format"]) && $_GET["format"] === "json") - { - header("content-type: application/json"); - exit(json_encode($userList)); - } - - $content = "

User List

\n"; - $content .= "
    \n"; - foreach($userList as $username) - $content .= "\t
  • " . page_renderer::render_username($username) . "
  • \n"; - $content .= "
\n"; - - exit(page_renderer::render_main("User List - $settings->sitename", $content)); - }); - - 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.

"); - } -]); - - - + + +register_module([ + "name" => "User list", + "version" => "0.1", + "author" => "Starbeamrainbowlabs", + "description" => "Adds a 'user-list' action that generates a list of users. Supports json output with 'format=json' in the queyr string.", + "id" => "page-user-list", + "code" => function() { + global $settings; + /** + * @api {get} ?action=user-list[format=json] List all users + * @apiName UserList + * @apiGroup Utility + * @apiPermission Anonymous + */ + + /* + * ██ ██ ███████ ███████ ██████ ██ ██ ███████ ████████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ███████ █████ ██████ █████ ██ ██ ███████ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ███████ ███████ ██ ██ ███████ ██ ███████ ██ + */ + add_action("user-list", function() { + global $env, $settings; + + $userList = array_keys(get_object_vars($settings->users)); + if(!empty($_GET["format"]) && $_GET["format"] === "json") + { + header("content-type: application/json"); + exit(json_encode($userList)); + } + + $content = "

User List

\n"; + $content .= "
    \n"; + foreach($userList as $username) + $content .= "\t
  • " . page_renderer::render_username($username) . "
  • \n"; + $content .= "
\n"; + + exit(page_renderer::render_main("User List - $settings->sitename", $content)); + }); + + 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.

"); + } +]); + + + register_module([ "name" => "Page viewer", @@ -6256,617 +6259,617 @@ register_module([ ]); - - -register_module([ - "name" => "Parsedown", - "version" => "0.9.9", - "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; - if($settings->clean_raw_html) - $parser->setMarkupEscaped(true); - else - $parser->setMarkupEscaped(false); - $result = $parser->text($source); - - return $result; - }); - - add_help_section("20-parser-default", "Editor Syntax", - "

$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!

-

Tips

-
    -
  • Put 2 spaces at the end of a line to add a soft line break. Leave a blank line to add a head line break (i.e. a new paragraph).
  • -
  • You can add an id to a header that you can link to. Put it in curly braces after the heading name like this: # 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]].
  • -
-

Extra Syntax

-

$settings->sitename's editor also supports some extra custom syntax, some of which is inspired by Mediawiki. - - - - - - - -
Type thisTo get thisComments
[[Internal link]]Internal LinkAn internal link.
[[Display Text|Internal link]]Display TextAn internal link with some display text.
![Alt text](http://example.com/path/to/image.png | 256x256 | right)Alt textAn 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)
Alt text
Alt text
An image with a caption that fits inside a 256px x 256px box, preserving aspect ratio. The caption is taken from the alt text.
![Alt text](Files/Cheese.png)Alt textAn 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.

-

Templating

-

$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.

-
Special Variables
-

$settings->sitename also supports a number of special built-in variables. Their syntax and function are described below:

- - - - - - - -
Type thisTo 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.
"); - } -]); - -/*** Parsedown versions *** - * Parsedown Core: 1.6.0 * - * Parsedown Extra: 0.7.0 * - **************************/ -$env->parsedown_paths = new stdClass(); -$env->parsedown_paths->parsedown = "https://cdn.rawgit.com/erusev/parsedown/3ebbd730b5c2cf5ce78bc1bf64071407fc6674b7/Parsedown.php"; -$env->parsedown_paths->parsedown_extra = "https://cdn.rawgit.com/erusev/parsedown-extra/11a44e076d02ffcc4021713398a60cd73f78b6f5/ParsedownExtra.php"; - -// Download parsedown and parsedown extra if they don't already exist -if(!file_exists("./Parsedown.php") || filesize("./Parsedown.php") === 0) - file_put_contents("./Parsedown.php", fopen($env->parsedown_paths->parsedown, "r")); -if(!file_exists("./ParsedownExtra.php") || filesize("./ParsedownExtra.php") === 0) - file_put_contents("./ParsedownExtra.php", fopen($env->parsedown_paths->parsedown_extra, "r")); - -require_once("./Parsedown.php"); -require_once("./ParsedownExtra.php"); - -/* - * ██████ █████ ██████ ███████ ███████ ██████ ██████ ██ ██ ███ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ - * ██████ ███████ ██████ ███████ █████ ██ ██ ██ ██ ██ █ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ███████ ███████ ██████ ██████ ███ ███ ██ ████ - * - * ███████ ██ ██ ████████ ███████ ███ ██ ███████ ██ ██████ ███ ██ ███████ - * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ████ ██ ██ - * █████ ███ ██ █████ ██ ██ ██ ███████ ██ ██ ██ ██ ██ ██ ███████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ███████ ██ ██ ██ ███████ ██ ████ ███████ ██ ██████ ██ ████ ███████ -*/ -class PeppermintParsedown extends ParsedownExtra -{ - private $internalLinkBase = "./%s"; - - protected $maxParamDepth = 0; - protected $paramStack = []; - - function __construct() - { - // Prioritise our internal link parsing over the regular link parsing - array_unshift($this->InlineTypes["["], "InternalLink"); - // Prioritise our image parser over the regular image parser - array_unshift($this->InlineTypes["!"], "ExtendedImage"); - - $this->inlineMarkerList .= "{"; - if(!isset($this->InlineTypes["{"]) or !is_array($this->InlineTypes["{"])) - $this->InlineTypes["{"] = []; - $this->InlineTypes["{"][] = "Template"; - } - - /* - * ████████ ███████ ███ ███ ██████ ██ █████ ████████ ██ ███ ██ ██████ - * ██ ██ ████ ████ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ - * ██ █████ ██ ████ ██ ██████ ██ ███████ ██ ██ ██ ██ ██ ██ ███ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ███████ ██ ██ ██ ███████ ██ ██ ██ ██ ██ ████ ██████ - */ - protected function inlineTemplate($fragment) - { - global $env, $pageindex; - - // Variable parsing - if(preg_match("/\{\{\{([^}]+)\}\}\}/", $fragment["text"], $matches)) - { - $params = []; - if(!empty($this->paramStack)) - { - $stackEntry = array_slice($this->paramStack, -1)[0]; - $params = !empty($stackEntry) ? $stackEntry["params"] : false; - } - - $variableKey = trim($matches[1]); - - $variableValue = false; - switch ($variableKey) - { - case "@": // Lists all variables and their values - if(!empty($params)) - { - $variableValue = " - \n"; - foreach($params as $key => $value) - { - $variableValue .= "\t\n"; - } - $variableValue .= "
KeyValue
" . $this->escapeText($key) . "" . $this->escapeText($value) . "
"; - } - break; - case "#": // Shows a stack trace - $variableValue = "
    \n"; - $variableValue .= "\t
  1. $env->page
  2. \n"; - foreach($this->paramStack as $curStackEntry) - { - $variableValue .= "\t
  3. " . $curStackEntry["pagename"] . "
  4. \n"; - } - $variableValue .= "
\n"; - break; - case "~": // Show requested page's name - if(!empty($this->paramStack)) - $variableValue = $this->escapeText($env->page); - break; - case "*": // Lists subpages - $subpages = get_subpages($pageindex, $env->page); - $variableValue = []; - foreach($subpages as $pagename => $depth) - { - $variableValue[] = $pagename; - } - $variableValue = implode(", ", $variableValue); - if(strlen($variableValue) === 0) - $variableValue = "(none yet!)"; - break; - case "+": // Shows a file gallery for subpages with files - // If the upload module isn't present, then there's no point - // in checking for uploaded files - if(!module_exists("feature-upload")) - break; - - $variableValue = []; - $subpages = get_subpages($pageindex, $env->page); - foreach($subpages as $pagename => $depth) - { - // Make sure that this is an uploaded file - if(!$pageindex->$pagename->uploadedfile) - continue; - - $mime_type = $pageindex->$pagename->uploadedfilemime; - - $previewSize = 300; - $previewUrl = "?action=preview&size=$previewSize&page=" . rawurlencode($pagename); - - $previewHtml = ""; - switch(substr($mime_type, 0, strpos($mime_type, "/"))) - { - case "video": - $previewHtml .= "\n"; - break; - case "audio": - $previewHtml .= "\n"; - break; - case "application": - case "image": - default: - $previewHtml .= "\n"; - break; - } - $previewHtml = "$previewHtml$pagename"; - - $variableValue[$pagename] = "
  • $previewHtml
  • "; - } - - if(count($variableValue) === 0) - $variableValue["default"] = "
  • (No files found)
  • \n"; - $variableValue = implode("\n", $variableValue); - $variableValue = ""; - break; - } - if(isset($params[$variableKey])) - { - $variableValue = $params[$variableKey]; - $variableValue = $this->escapeText($variableValue); - } - - if($variableValue !== false) - { - return [ - "extent" => strlen($matches[0]), - "markup" => $variableValue - ]; - } - } - else if(preg_match("/\{\{([^}]+)\}\}/", $fragment["text"], $matches)) - { - $templateElement = $this->templateHandler($matches[1]); - - if(!empty($templateElement)) - { - return [ - "extent" => strlen($matches[0]), - "element" => $templateElement - ]; - } - } - } - - protected function templateHandler($source) - { - global $pageindex, $env; - - - $parts = preg_split("/\\||¦/", trim($source, "{}")); - $parts = array_map("trim", $parts); - - // Extract the name of the template page - $templatePagename = array_shift($parts); - // If the page that we are supposed to use as the tempalte doesn't - // exist, then there's no point in continuing. - if(empty($pageindex->$templatePagename)) - return false; - - // Parse the parameters - $this->maxParamDepth++; - $params = []; - $i = 0; - foreach($parts as $part) - { - if(strpos($part, "=") !== false) - { - // This param contains an equals sign, so it's a named parameter - $keyValuePair = explode("=", $part, 2); - $keyValuePair = array_map("trim", $keyValuePair); - $params[$keyValuePair[0]] = $keyValuePair[1]; - } - else - { - // This isn't a named parameter - $params["$i"] = trim($part); - - $i++; - } - } - // Add the parsed parameters to the parameter stack - $this->paramStack[] = [ - "pagename" => $templatePagename, - "params" => $params - ]; - - $templateFilePath = $env->storage_prefix . $pageindex->$templatePagename->filename; - - $parsedTemplateSource = $this->text(file_get_contents($templateFilePath)); - - // Remove the parsed parameters from the stack - array_pop($this->paramStack); - - return [ - "name" => "div", - "text" => $parsedTemplateSource, - "attributes" => [ - "class" => "template" - ] - ]; - } - - /* - * ██ ███ ██ ████████ ███████ ██████ ███ ██ █████ ██ - * ██ ████ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ █████ ██████ ██ ██ ██ ███████ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ████ ██ ███████ ██ ██ ██ ████ ██ ██ ███████ - * - * ██ ██ ███ ██ ██ ██ ███████ - * ██ ██ ████ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ █████ ███████ - * ██ ██ ██ ██ ██ ██ ██ ██ - * ███████ ██ ██ ████ ██ ██ ███████ - */ - protected function inlineInternalLink($fragment) - { - global $pageindex, $env; - - if(preg_match('/^\[\[([^\]]*)\]\]([^\s!?",.()\[\]{}*=+\/]*)/u', $fragment["text"], $matches)) - { - $linkPage = trim($matches[1]); - $display = $linkPage . trim($matches[2]); - if(strpos($matches[1], "|") !== false || strpos($matches[1], "¦") !== false) - { - // We have a bar character - $parts = preg_split("/\\||¦/", $matches[1], 2); - $linkPage = trim($parts[0]); // The page to link to - $display = trim($parts[1]); // The text to display - } - - $hashCode = ""; - if(strpos($linkPage, "#") !== false) - { - // We want to link to a subsection of a page - $hashCode = substr($linkPage, strpos($linkPage, "#") + 1); - $linkPage = substr($linkPage, 0, strpos($linkPage, "#")); - - // If $linkPage is empty then we want to link to the current page - if(strlen($linkPage) === 0) - $linkPage = $env->page; - } - - // If the page doesn't exist, check varying different - // capitalisations to see if it exists under some variant. - if(empty($pageindex->$linkPage)) - { - if(!empty($pageindex->{ucfirst($linkPage)})) - $linkPage = ucfirst($linkPage); - else if(!empty($pageindex->{ucwords($linkPage)})) - $linkPage = ucwords($linkPage); - } - - - // Construct the full url - $linkUrl = str_replace( - "%s", rawurlencode($linkPage), - $this->internalLinkBase - ); - - if(strlen($hashCode) > 0) - $linkUrl .= "#$hashCode"; - - $result = [ - "extent" => strlen($matches[0]), - "element" => [ - "name" => "a", - "text" => $display, - "attributes" => [ - "href" => $linkUrl - ] - ] - ]; - - if(empty($pageindex->{makepathsafe($linkPage)})) - $result["element"]["attributes"]["class"] = "redlink"; - - return $result; - } - return; - } - - /* - * ███████ ██ ██ ████████ ███████ ███ ██ ██████ ███████ ██████ - * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ - * █████ ███ ██ █████ ██ ██ ██ ██ ██ █████ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ███████ ██ ██ ██ ███████ ██ ████ ██████ ███████ ██████ - * - * ██ ███ ███ █████ ██████ ███████ ███████ - * ██ ████ ████ ██ ██ ██ ██ ██ - * ██ ██ ████ ██ ███████ ██ ███ █████ ███████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██████ ███████ ███████ - */ - protected function inlineExtendedImage($fragment) - { - global $pageindex; - - if(preg_match('/^!\[(.*)\]\(([^|¦)]+)\s*(?:(?:\||¦)([^|¦)]*))?(?:(?:\||¦)([^|¦)]*))?(?:(?:\||¦)([^)]*))?\)/', $fragment["text"], $matches)) - { - /* - * 0 - Everything - * 1 - Alt text - * 2 - Url - * 3 - First param (optional) - * 4 - Second param (optional) - * 5 - Third param (optional) - */ - $altText = $matches[1]; - $imageUrl = trim(str_replace("&", "&", $matches[2])); // Decode & to allow it in preview urls - $param1 = empty($matches[3]) ? false : strtolower(trim($matches[3])); - $param2 = empty($matches[4]) ? false : strtolower(trim($matches[4])); - $param3 = empty($matches[5]) ? false : strtolower(trim($matches[5])); - $floatDirection = false; - $imageSize = false; - $imageCaption = false; - $shortImageUrl = false; - - if($this->isFloatValue($param1)) - { - // Param 1 is a valid css float: ... value - $floatDirection = $param1; - $imageSize = $this->parseSizeSpec($param2); - } - else if($this->isFloatValue($param2)) - { - // Param 2 is a valid css float: ... value - $floatDirection = $param2; - $imageSize = $this->parseSizeSpec($param1); - } - else if($this->isFloatValue($param3)) - { - $floatDirection = $param3; - $imageSize = $this->parseSizeSpec($param1); - } - else if($param1 === false and $param2 === false) - { - // Neither params were specified - $floatDirection = false; - $imageSize = false; - } - else - { - // Neither of them are floats, but at least one is specified - // This must mean that the first param is a size spec like - // 250x128. - $imageSize = $this->parseSizeSpec($param1); - } - - if($param1 !== false && strtolower(trim($param1)) == "caption") - $imageCaption = true; - if($param2 !== false && strtolower(trim($param2)) == "caption") - $imageCaption = true; - if($param3 !== false && strtolower(trim($param3)) == "caption") - $imageCaption = true; - - //echo("Image url: $imageUrl, Pageindex entry: " . var_export(isset($pageindex->$imageUrl), true) . "\n"); - - if(isset($pageindex->$imageUrl) and $pageindex->$imageUrl->uploadedfile) - { - // We have a short url! Expand it. - $shortImageUrl = $imageUrl; - $imageUrl = "index.php?action=preview&size=" . max($imageSize["x"], $imageSize["y"]) ."&page=" . rawurlencode($imageUrl); - } - - $style = ""; - if($imageSize !== false) - $style .= " max-width: " . $imageSize["x"] . "px; max-height: " . $imageSize["y"] . "px;"; - if($floatDirection) - $style .= " float: $floatDirection;"; - - $urlExtension = pathinfo($imageUrl, PATHINFO_EXTENSION); - $urlType = system_extension_mime_type($urlExtension); - $result = []; - switch(substr($urlType, 0, strpos($urlType, "/"))) - { - case "audio": - $result = [ - "extent" => strlen($matches[0]), - "element" => [ - "name" => "audio", - "text" => $altText, - "attributes" => [ - "src" => $imageUrl, - "controls" => "controls", - "preload" => "metadata", - "style" => trim($style) - ] - ] - ]; - break; - case "video": - $result = [ - "extent" => strlen($matches[0]), - "element" => [ - "name" => "video", - "text" => $altText, - "attributes" => [ - "src" => $imageUrl, - "controls" => "controls", - "preload" => "metadata", - "style" => trim($style) - ] - ] - ]; - break; - case "image": - default: - // If we can't work out what it is, then assume it's an image - $result = [ - "extent" => strlen($matches[0]), - "element" => [ - "name" => "img", - "attributes" => [ - "src" => $imageUrl, - "alt" => $altText, - "title" => $altText, - "style" => trim($style) - ] - ] - ]; - break; - } - - // ~ Image linker ~ - - $imageHref = $shortImageUrl !== false ? "?page=" . rawurlencode($shortImageUrl) : $imageUrl; - $result["element"] = [ - "name" => "a", - "attributes" => [ - "href" => $imageHref - ], - "text" => [$result["element"]], - "handler" => "elements" - ]; - - // ~ - - if($imageCaption) - { - $rawStyle = $result["element"]["attributes"]["style"]; - $containerStyle = preg_replace('/^.*float/', "float", $rawStyle); - $mediaStyle = preg_replace('/\s*float.*;/', "", $rawStyle); - $result["element"] = [ - "name" => "figure", - "attributes" => [ - "style" => $containerStyle - ], - "text" => [ - $result["element"], - [ - "name" => "figcaption", - "text" => $altText - ], - ], - "handler" => "elements" - ]; - $result["element"]["text"][0]["attributes"]["style"] = $mediaStyle; - } - return $result; - } - } - - # ~ - # Utility Methods - # ~ - - private function isFloatValue($value) - { - return in_array(strtolower($value), [ "left", "right" ]); - } - - private function parseSizeSpec($text) - { - if(strpos($text, "x") === false) - return false; - $parts = explode("x", $text, 2); - - if(count($parts) != 2) - return false; - - array_map("trim", $parts); - array_map("intval", $parts); - - if(in_array(0, $parts)) - return false; - - return [ - "x" => $parts[0], - "y" => $parts[1] - ]; - } - - protected function escapeText($text) - { - return htmlentities($text, ENT_COMPAT | ENT_HTML5); - } - - /** - * Sets the base url to be used for internal links. '%s' will be replaced - * with a URL encoded version of the page name. - * @param string $url The url to use when parsing internal links. - */ - public function setInternalLinkBase($url) - { - $this->internalLinkBase = $url; - } -} - - - + + +register_module([ + "name" => "Parsedown", + "version" => "0.9.9", + "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; + if($settings->clean_raw_html) + $parser->setMarkupEscaped(true); + else + $parser->setMarkupEscaped(false); + $result = $parser->text($source); + + return $result; + }); + + add_help_section("20-parser-default", "Editor Syntax", + "

    $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!

    +

    Tips

    +
      +
    • Put 2 spaces at the end of a line to add a soft line break. Leave a blank line to add a head line break (i.e. a new paragraph).
    • +
    • You can add an id to a header that you can link to. Put it in curly braces after the heading name like this: # 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]].
    • +
    +

    Extra Syntax

    +

    $settings->sitename's editor also supports some extra custom syntax, some of which is inspired by Mediawiki. + + + + + + + +
    Type thisTo get thisComments
    [[Internal link]]Internal LinkAn internal link.
    [[Display Text|Internal link]]Display TextAn internal link with some display text.
    ![Alt text](http://example.com/path/to/image.png | 256x256 | right)Alt textAn 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)
    Alt text
    Alt text
    An image with a caption that fits inside a 256px x 256px box, preserving aspect ratio. The caption is taken from the alt text.
    ![Alt text](Files/Cheese.png)Alt textAn 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.

    +

    Templating

    +

    $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.

    +
    Special Variables
    +

    $settings->sitename also supports a number of special built-in variables. Their syntax and function are described below:

    + + + + + + + +
    Type thisTo 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.
    "); + } +]); + +/*** Parsedown versions *** + * Parsedown Core: 1.6.0 * + * Parsedown Extra: 0.7.0 * + **************************/ +$env->parsedown_paths = new stdClass(); +$env->parsedown_paths->parsedown = "https://cdn.rawgit.com/erusev/parsedown/3ebbd730b5c2cf5ce78bc1bf64071407fc6674b7/Parsedown.php"; +$env->parsedown_paths->parsedown_extra = "https://cdn.rawgit.com/erusev/parsedown-extra/11a44e076d02ffcc4021713398a60cd73f78b6f5/ParsedownExtra.php"; + +// Download parsedown and parsedown extra if they don't already exist +if(!file_exists("./Parsedown.php") || filesize("./Parsedown.php") === 0) + file_put_contents("./Parsedown.php", fopen($env->parsedown_paths->parsedown, "r")); +if(!file_exists("./ParsedownExtra.php") || filesize("./ParsedownExtra.php") === 0) + file_put_contents("./ParsedownExtra.php", fopen($env->parsedown_paths->parsedown_extra, "r")); + +require_once("./Parsedown.php"); +require_once("./ParsedownExtra.php"); + +/* + * ██████ █████ ██████ ███████ ███████ ██████ ██████ ██ ██ ███ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ + * ██████ ███████ ██████ ███████ █████ ██ ██ ██ ██ ██ █ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ███████ ███████ ██████ ██████ ███ ███ ██ ████ + * + * ███████ ██ ██ ████████ ███████ ███ ██ ███████ ██ ██████ ███ ██ ███████ + * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ████ ██ ██ + * █████ ███ ██ █████ ██ ██ ██ ███████ ██ ██ ██ ██ ██ ██ ███████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ███████ ██ ██ ██ ███████ ██ ████ ███████ ██ ██████ ██ ████ ███████ +*/ +class PeppermintParsedown extends ParsedownExtra +{ + private $internalLinkBase = "./%s"; + + protected $maxParamDepth = 0; + protected $paramStack = []; + + function __construct() + { + // Prioritise our internal link parsing over the regular link parsing + array_unshift($this->InlineTypes["["], "InternalLink"); + // Prioritise our image parser over the regular image parser + array_unshift($this->InlineTypes["!"], "ExtendedImage"); + + $this->inlineMarkerList .= "{"; + if(!isset($this->InlineTypes["{"]) or !is_array($this->InlineTypes["{"])) + $this->InlineTypes["{"] = []; + $this->InlineTypes["{"][] = "Template"; + } + + /* + * ████████ ███████ ███ ███ ██████ ██ █████ ████████ ██ ███ ██ ██████ + * ██ ██ ████ ████ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ + * ██ █████ ██ ████ ██ ██████ ██ ███████ ██ ██ ██ ██ ██ ██ ███ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ███████ ██ ██ ██ ███████ ██ ██ ██ ██ ██ ████ ██████ + */ + protected function inlineTemplate($fragment) + { + global $env, $pageindex; + + // Variable parsing + if(preg_match("/\{\{\{([^}]+)\}\}\}/", $fragment["text"], $matches)) + { + $params = []; + if(!empty($this->paramStack)) + { + $stackEntry = array_slice($this->paramStack, -1)[0]; + $params = !empty($stackEntry) ? $stackEntry["params"] : false; + } + + $variableKey = trim($matches[1]); + + $variableValue = false; + switch ($variableKey) + { + case "@": // Lists all variables and their values + if(!empty($params)) + { + $variableValue = " + \n"; + foreach($params as $key => $value) + { + $variableValue .= "\t\n"; + } + $variableValue .= "
    KeyValue
    " . $this->escapeText($key) . "" . $this->escapeText($value) . "
    "; + } + break; + case "#": // Shows a stack trace + $variableValue = "
      \n"; + $variableValue .= "\t
    1. $env->page
    2. \n"; + foreach($this->paramStack as $curStackEntry) + { + $variableValue .= "\t
    3. " . $curStackEntry["pagename"] . "
    4. \n"; + } + $variableValue .= "
    \n"; + break; + case "~": // Show requested page's name + if(!empty($this->paramStack)) + $variableValue = $this->escapeText($env->page); + break; + case "*": // Lists subpages + $subpages = get_subpages($pageindex, $env->page); + $variableValue = []; + foreach($subpages as $pagename => $depth) + { + $variableValue[] = $pagename; + } + $variableValue = implode(", ", $variableValue); + if(strlen($variableValue) === 0) + $variableValue = "(none yet!)"; + break; + case "+": // Shows a file gallery for subpages with files + // If the upload module isn't present, then there's no point + // in checking for uploaded files + if(!module_exists("feature-upload")) + break; + + $variableValue = []; + $subpages = get_subpages($pageindex, $env->page); + foreach($subpages as $pagename => $depth) + { + // Make sure that this is an uploaded file + if(!$pageindex->$pagename->uploadedfile) + continue; + + $mime_type = $pageindex->$pagename->uploadedfilemime; + + $previewSize = 300; + $previewUrl = "?action=preview&size=$previewSize&page=" . rawurlencode($pagename); + + $previewHtml = ""; + switch(substr($mime_type, 0, strpos($mime_type, "/"))) + { + case "video": + $previewHtml .= "\n"; + break; + case "audio": + $previewHtml .= "\n"; + break; + case "application": + case "image": + default: + $previewHtml .= "\n"; + break; + } + $previewHtml = "$previewHtml$pagename"; + + $variableValue[$pagename] = "
  • $previewHtml
  • "; + } + + if(count($variableValue) === 0) + $variableValue["default"] = "
  • (No files found)
  • \n"; + $variableValue = implode("\n", $variableValue); + $variableValue = ""; + break; + } + if(isset($params[$variableKey])) + { + $variableValue = $params[$variableKey]; + $variableValue = $this->escapeText($variableValue); + } + + if($variableValue !== false) + { + return [ + "extent" => strlen($matches[0]), + "markup" => $variableValue + ]; + } + } + else if(preg_match("/\{\{([^}]+)\}\}/", $fragment["text"], $matches)) + { + $templateElement = $this->templateHandler($matches[1]); + + if(!empty($templateElement)) + { + return [ + "extent" => strlen($matches[0]), + "element" => $templateElement + ]; + } + } + } + + protected function templateHandler($source) + { + global $pageindex, $env; + + + $parts = preg_split("/\\||¦/", trim($source, "{}")); + $parts = array_map("trim", $parts); + + // Extract the name of the template page + $templatePagename = array_shift($parts); + // If the page that we are supposed to use as the tempalte doesn't + // exist, then there's no point in continuing. + if(empty($pageindex->$templatePagename)) + return false; + + // Parse the parameters + $this->maxParamDepth++; + $params = []; + $i = 0; + foreach($parts as $part) + { + if(strpos($part, "=") !== false) + { + // This param contains an equals sign, so it's a named parameter + $keyValuePair = explode("=", $part, 2); + $keyValuePair = array_map("trim", $keyValuePair); + $params[$keyValuePair[0]] = $keyValuePair[1]; + } + else + { + // This isn't a named parameter + $params["$i"] = trim($part); + + $i++; + } + } + // Add the parsed parameters to the parameter stack + $this->paramStack[] = [ + "pagename" => $templatePagename, + "params" => $params + ]; + + $templateFilePath = $env->storage_prefix . $pageindex->$templatePagename->filename; + + $parsedTemplateSource = $this->text(file_get_contents($templateFilePath)); + + // Remove the parsed parameters from the stack + array_pop($this->paramStack); + + return [ + "name" => "div", + "text" => $parsedTemplateSource, + "attributes" => [ + "class" => "template" + ] + ]; + } + + /* + * ██ ███ ██ ████████ ███████ ██████ ███ ██ █████ ██ + * ██ ████ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ █████ ██████ ██ ██ ██ ███████ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ████ ██ ███████ ██ ██ ██ ████ ██ ██ ███████ + * + * ██ ██ ███ ██ ██ ██ ███████ + * ██ ██ ████ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ █████ ███████ + * ██ ██ ██ ██ ██ ██ ██ ██ + * ███████ ██ ██ ████ ██ ██ ███████ + */ + protected function inlineInternalLink($fragment) + { + global $pageindex, $env; + + if(preg_match('/^\[\[([^\]]*)\]\]([^\s!?",.()\[\]{}*=+\/]*)/u', $fragment["text"], $matches)) + { + $linkPage = trim($matches[1]); + $display = $linkPage . trim($matches[2]); + if(strpos($matches[1], "|") !== false || strpos($matches[1], "¦") !== false) + { + // We have a bar character + $parts = preg_split("/\\||¦/", $matches[1], 2); + $linkPage = trim($parts[0]); // The page to link to + $display = trim($parts[1]); // The text to display + } + + $hashCode = ""; + if(strpos($linkPage, "#") !== false) + { + // We want to link to a subsection of a page + $hashCode = substr($linkPage, strpos($linkPage, "#") + 1); + $linkPage = substr($linkPage, 0, strpos($linkPage, "#")); + + // If $linkPage is empty then we want to link to the current page + if(strlen($linkPage) === 0) + $linkPage = $env->page; + } + + // If the page doesn't exist, check varying different + // capitalisations to see if it exists under some variant. + if(empty($pageindex->$linkPage)) + { + if(!empty($pageindex->{ucfirst($linkPage)})) + $linkPage = ucfirst($linkPage); + else if(!empty($pageindex->{ucwords($linkPage)})) + $linkPage = ucwords($linkPage); + } + + + // Construct the full url + $linkUrl = str_replace( + "%s", rawurlencode($linkPage), + $this->internalLinkBase + ); + + if(strlen($hashCode) > 0) + $linkUrl .= "#$hashCode"; + + $result = [ + "extent" => strlen($matches[0]), + "element" => [ + "name" => "a", + "text" => $display, + "attributes" => [ + "href" => $linkUrl + ] + ] + ]; + + if(empty($pageindex->{makepathsafe($linkPage)})) + $result["element"]["attributes"]["class"] = "redlink"; + + return $result; + } + return; + } + + /* + * ███████ ██ ██ ████████ ███████ ███ ██ ██████ ███████ ██████ + * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ + * █████ ███ ██ █████ ██ ██ ██ ██ ██ █████ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ███████ ██ ██ ██ ███████ ██ ████ ██████ ███████ ██████ + * + * ██ ███ ███ █████ ██████ ███████ ███████ + * ██ ████ ████ ██ ██ ██ ██ ██ + * ██ ██ ████ ██ ███████ ██ ███ █████ ███████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██████ ███████ ███████ + */ + protected function inlineExtendedImage($fragment) + { + global $pageindex; + + if(preg_match('/^!\[(.*)\]\(([^|¦)]+)\s*(?:(?:\||¦)([^|¦)]*))?(?:(?:\||¦)([^|¦)]*))?(?:(?:\||¦)([^)]*))?\)/', $fragment["text"], $matches)) + { + /* + * 0 - Everything + * 1 - Alt text + * 2 - Url + * 3 - First param (optional) + * 4 - Second param (optional) + * 5 - Third param (optional) + */ + $altText = $matches[1]; + $imageUrl = trim(str_replace("&", "&", $matches[2])); // Decode & to allow it in preview urls + $param1 = empty($matches[3]) ? false : strtolower(trim($matches[3])); + $param2 = empty($matches[4]) ? false : strtolower(trim($matches[4])); + $param3 = empty($matches[5]) ? false : strtolower(trim($matches[5])); + $floatDirection = false; + $imageSize = false; + $imageCaption = false; + $shortImageUrl = false; + + if($this->isFloatValue($param1)) + { + // Param 1 is a valid css float: ... value + $floatDirection = $param1; + $imageSize = $this->parseSizeSpec($param2); + } + else if($this->isFloatValue($param2)) + { + // Param 2 is a valid css float: ... value + $floatDirection = $param2; + $imageSize = $this->parseSizeSpec($param1); + } + else if($this->isFloatValue($param3)) + { + $floatDirection = $param3; + $imageSize = $this->parseSizeSpec($param1); + } + else if($param1 === false and $param2 === false) + { + // Neither params were specified + $floatDirection = false; + $imageSize = false; + } + else + { + // Neither of them are floats, but at least one is specified + // This must mean that the first param is a size spec like + // 250x128. + $imageSize = $this->parseSizeSpec($param1); + } + + if($param1 !== false && strtolower(trim($param1)) == "caption") + $imageCaption = true; + if($param2 !== false && strtolower(trim($param2)) == "caption") + $imageCaption = true; + if($param3 !== false && strtolower(trim($param3)) == "caption") + $imageCaption = true; + + //echo("Image url: $imageUrl, Pageindex entry: " . var_export(isset($pageindex->$imageUrl), true) . "\n"); + + if(isset($pageindex->$imageUrl) and $pageindex->$imageUrl->uploadedfile) + { + // We have a short url! Expand it. + $shortImageUrl = $imageUrl; + $imageUrl = "index.php?action=preview&size=" . max($imageSize["x"], $imageSize["y"]) ."&page=" . rawurlencode($imageUrl); + } + + $style = ""; + if($imageSize !== false) + $style .= " max-width: " . $imageSize["x"] . "px; max-height: " . $imageSize["y"] . "px;"; + if($floatDirection) + $style .= " float: $floatDirection;"; + + $urlExtension = pathinfo($imageUrl, PATHINFO_EXTENSION); + $urlType = system_extension_mime_type($urlExtension); + $result = []; + switch(substr($urlType, 0, strpos($urlType, "/"))) + { + case "audio": + $result = [ + "extent" => strlen($matches[0]), + "element" => [ + "name" => "audio", + "text" => $altText, + "attributes" => [ + "src" => $imageUrl, + "controls" => "controls", + "preload" => "metadata", + "style" => trim($style) + ] + ] + ]; + break; + case "video": + $result = [ + "extent" => strlen($matches[0]), + "element" => [ + "name" => "video", + "text" => $altText, + "attributes" => [ + "src" => $imageUrl, + "controls" => "controls", + "preload" => "metadata", + "style" => trim($style) + ] + ] + ]; + break; + case "image": + default: + // If we can't work out what it is, then assume it's an image + $result = [ + "extent" => strlen($matches[0]), + "element" => [ + "name" => "img", + "attributes" => [ + "src" => $imageUrl, + "alt" => $altText, + "title" => $altText, + "style" => trim($style) + ] + ] + ]; + break; + } + + // ~ Image linker ~ + + $imageHref = $shortImageUrl !== false ? "?page=" . rawurlencode($shortImageUrl) : $imageUrl; + $result["element"] = [ + "name" => "a", + "attributes" => [ + "href" => $imageHref + ], + "text" => [$result["element"]], + "handler" => "elements" + ]; + + // ~ + + if($imageCaption) + { + $rawStyle = $result["element"]["attributes"]["style"]; + $containerStyle = preg_replace('/^.*float/', "float", $rawStyle); + $mediaStyle = preg_replace('/\s*float.*;/', "", $rawStyle); + $result["element"] = [ + "name" => "figure", + "attributes" => [ + "style" => $containerStyle + ], + "text" => [ + $result["element"], + [ + "name" => "figcaption", + "text" => $altText + ], + ], + "handler" => "elements" + ]; + $result["element"]["text"][0]["attributes"]["style"] = $mediaStyle; + } + return $result; + } + } + + # ~ + # Utility Methods + # ~ + + private function isFloatValue($value) + { + return in_array(strtolower($value), [ "left", "right" ]); + } + + private function parseSizeSpec($text) + { + if(strpos($text, "x") === false) + return false; + $parts = explode("x", $text, 2); + + if(count($parts) != 2) + return false; + + array_map("trim", $parts); + array_map("intval", $parts); + + if(in_array(0, $parts)) + return false; + + return [ + "x" => $parts[0], + "y" => $parts[1] + ]; + } + + protected function escapeText($text) + { + return htmlentities($text, ENT_COMPAT | ENT_HTML5); + } + + /** + * Sets the base url to be used for internal links. '%s' will be replaced + * with a URL encoded version of the page name. + * @param string $url The url to use when parsing internal links. + */ + public function setInternalLinkBase($url) + { + $this->internalLinkBase = $url; + } +} + + + // %next_module% // ////////////////////////////////////////////////////////////////// diff --git a/modules/extra-sidebar.php b/modules/extra-sidebar.php index 2621878..e503180 100644 --- a/modules/extra-sidebar.php +++ b/modules/extra-sidebar.php @@ -100,7 +100,10 @@ function render_sidebar($pageindex, $root_pagename = "") // The current page is the same as the root page, skip it if($pagename == $root_pagename) continue; - + + // If the page already appears on the sidebar, skip it + if(preg_match("/>$pagename<\a>/m",$result)===1) + continue; // If the part of the current pagename that comes after the root // pagename has a slash in it, skip it as it is a sub-sub page. From e2f87f80def983984be0ca9498bf0f1751f4e2fe Mon Sep 17 00:00:00 2001 From: Starbeamrainbowlabs Date: Sun, 28 May 2017 19:42:17 +0100 Subject: [PATCH 2/3] Tweak the formatting of the new code I'm just picky ;-) --- modules/extra-sidebar.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/extra-sidebar.php b/modules/extra-sidebar.php index e503180..d1060cf 100644 --- a/modules/extra-sidebar.php +++ b/modules/extra-sidebar.php @@ -102,7 +102,7 @@ function render_sidebar($pageindex, $root_pagename = "") continue; // If the page already appears on the sidebar, skip it - if(preg_match("/>$pagename<\a>/m",$result)===1) + if(preg_match("/>$pagename<\a>/m", $result) === 1) continue; // If the part of the current pagename that comes after the root From d02bccf14543d47a5c8556824a2088cc4523b409 Mon Sep 17 00:00:00 2001 From: Isabeau Kisler Date: Sun, 28 May 2017 15:45:05 -0700 Subject: [PATCH 3/3] Fix repeated page names on sidebar - remove index.php Remove changes from build/index.php --- build/index.php | 7139 +++++++++++++++++++++++------------------------ 1 file changed, 3568 insertions(+), 3571 deletions(-) diff --git a/build/index.php b/build/index.php index ca4713a..7bd15a0 100644 --- a/build/index.php +++ b/build/index.php @@ -2,351 +2,351 @@ $start_time = microtime(true); mb_internal_encoding("UTF-8"); - -/* - * Pepperminty Wiki - * ================ - * Inspired by Minty Wiki by am2064 - * Link: https://github.com/am2064/Minty-Wiki - * - * Credits: - * Code by @Starbeamrainbowlabs - * Parsedown - by erusev and others on github from http://parsedown.org/ - * Mathematical Expression rendering - * Code: @con-f-use - * Rendering: MathJax (https://www.mathjax.org/) - * Bug reports: - * #2 - Incorrect closing tag - nibreh - * #8 - Rogue tag - nibreh - */ -$guiConfig = <<<'GUICONFIG' -{ - "sitename": {"type": "text", "description": "Your wiki's name.", "default": "Pepperminty Wiki"}, - "defaultpage": {"type": "text", "description": "The name of the page that will act as the home page for the wiki. This page will be served if you don't specify a page.", "default": "Main Page"}, - "admindetails_name": {"type": "text", "description": "Your name as the wiki administrator.", "default": "Administrator"}, - "admindetails_email": {"type": "email", "description": "Your email address as the wiki administrator. Will be displayed as a support contact address.", "default": "admin@localhost"}, - "favicon": {"type": "url", "description": "A url that points to the favicon you want to use for your wiki. This image By default this is set to a data: url of a Peppermint (Credit: by bluefrog23, source: https://openclipart.org/detail/19571/peppermint-candy-by-bluefrog23)", "default": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAB3VBMVEXhERHbKCjeVVXjb2/kR0fhKirdHBziDg6qAADaHh7qLy/pdXXUNzfMAADYPj7ZPDzUNzfbHx/fERHpamrqMTHgExPdHx/bLCzhLS3fVFTjT0/ibm7kRkbiLi7aKirdISHeFBTqNDTpeHjgERHYJCTVODjYQkLaPj6/AADVOTnpbW3cIyPdFRXcJCThMjLiTU3ibW3fVVXaKyvcERH4ODj+8fH/////fHz+Fxf4KSn0UFD/CAj/AAD/Xl7/wMD/EhL//v70xMT/+Pj/iYn/HBz/g4P/IyP/Kyv/7Oz0QUH/9PT/+vr/ior/Dg7/vr7/aGj/QED/bGz/AQH/ERH/Jib/R0f/goL/0dH/qan/YWH/7e3/Cwv4R0f/MTH/enr/vLz/u7v/cHD/oKD/n5//aWn+9/f/k5P/0tL/trb/QUH/cXH/dHT/wsL/DQ3/p6f/DAz/1dX/XV3/kpL/i4v/Vlb/2Nj/9/f/pKT+7Oz/V1f/iIj/jIz/r6//Zmb/lZX/j4//T0//Dw/4MzP/GBj/+fn/o6P/TEz/xMT/b2//Tk7/OTn/HR3/hIT/ODj/Y2P/CQn/ZGT/6Oj0UlL/Gxv//f3/Bwf/YmL/6+v0w8P/Cgr/tbX0QkL+9fX4Pz/qNzd0dFHLAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfeCxINNSdmw510AAAA5ElEQVQYGQXBzSuDAQCA8eexKXOwmSZepa1JiPJxsJOrCwcnuchBjg4O/gr7D9zk4uAgJzvuMgcTpYxaUZvSm5mUj7TX7ycAqvoLIJBwStVbP0Hom1Z/ejoxrbaR1Jz6nWinbKWttGRgMSSjanPktRY6mB9WtRNTn7Ilh7LxnNpKq2/x5LnBitfz+hx0qxUaxhZ6vwqq9bx6f2XXvuUl9SVQS38NR7cvln3v15tZ9bQpuWDtZN3Lgh5DWJex3Y+z1KrVhw21+CiM74WZo83DiXq0dVBDYNJkFEU7WrwDAZhRtQrwDzwKQbT6GboLAAAAAElFTkSuQmCC"}, - "logo_url": {"type": "url", "description": "A url that points to the site's logo. Leave blank to disable. When enabled the logo will be inserted next to the site name on every page.", "default": "//starbeamrainbowlabs.com/images/logos/peppermint.png"}, - "logo_position": {"type": "text", "description": "The side of the site name at which the logo should be placed.", "default": "left"}, - "show_subpages": {"type": "text", "description": "Whether to show a list of subpages at the bottom of the page.", "default": true}, - "subpages_display_depth": {"type": "text", "description": "The depth to which we should display when listing subpages at the bottom the page.", "default": 3}, - "footer_message": {"type": "textarea", "description": "A message that will appear at the bottom of every page. May contain HTML.", "default": "All content is under this license. Please make sure that you read and understand the license, especially if you are thinking about copying some (or all) of this site's content, as it may restrict you from doing so."}, - "editing_message": {"type": "textarea", "description": "A message that will appear just before the submit button on the editing page. May contain HTML.", "default": "Formatting help (Markdown Cheatsheet)
    \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"}, - "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 sha256 (or sha3 if you have that option turned on)", "default": { - "admin": { - "email": "admin@somewhere.com", - "password": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" - }, - "user": { - "email": "example@example.net", - "password": "873ac9ffea4dd04fa719e8920cd6938f0c23cd678af330939cff53c3d2855f34" - } - }}, - "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": "⚙ " }, - "use_sha3": {"type": "checkbox", "description": "Whether to use the new sha3 hashing algorithm for passwords etc.", "default": false }, - "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.
    ", "default": [ - "user-status", - [ - "Home", - "index.php" - ], - "search", - [ - "Read", - "index.php?page={page}" - ], - [ - "Edit", - "index.php?action=edit&page={page}" - ], - [ - "All Pages", - "index.php?action=list" - ], - "menu" - ]}, - "nav_links_extra": {"type": "nav", "description": "An array of additional links in the above format that will be shown under \"More\" subsection.", "default": [ - [ - "🕓 Page History", - "?action=history&page={page}" - ], - [ - "🎫 All Tags", - "index.php?action=list-tags" - ], - [ - "⇝ Random Page", - "?action=random" - ], - [ - "Recent changes", - "?action=recent-changes" - ], - [ - "🡅 Upload", - "index.php?action=upload" - ], - [ - "⌧ ◆Delete", - "index.php?action=delete&page={page}" - ], - [ - "⎘ ◆Move", - "index.php?action=move&page={page}" - ], - [ - "🔐 ◆Toggle Protection", - "index.php?action=protect&page={page}" - ], - [ - "⚙ ◆Edit master settings", - "index.php?action=configure" - ] - ]}, - "nav_links_bottom": {"type": "nav", "description": "An array of links in the above format that will be shown at the bottom of the page.", "default": [ - [ - "🖶 Printable version", - "index.php?action=view&mode=printable&page={page}" - ], - [ - "Credits", - "index.php?action=credits" - ], - [ - "Help", - "index.php?action=help" - ] - ]}, - "comment_max_length": {"type": "number", "description": "The maximum allowed length, in characters, for comments", "default": 5000 }, - "comment_min_length": {"type": "number", "description": "The minimum allowed length, in characters, for comments", "default": 10 }, - "comment_time_icon": {"type": "text", "description": "The icon to show next to the time that a comment was posted.", "default": "🕗" }, - "upload_enabled": {"type": "checkbox", "description": "Whether to allow uploads to the server.", "default": true}, - "upload_allowed_file_types": {"type": "array", "description": "An array of mime types that are allowed to be uploaded.", "default": [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/svg+xml", - "video/mp4", - "video/webm", - "audio/mp4", - "audio/mpeg" - ]}, - "preview_file_type": {"type": "text", "description": "The default file type for previews.", "default": "image/png"}, - "default_preview_size": {"type": "number", "description": "The default size of preview images in pixels.", "default": 640}, - "mime_extension_mappings_location": {"type": "text", "description": "The location of a file that maps mime types onto file extensions and vice versa. Used to generate the file extension for an uploaded file. See the configuration guide for windows instructions.", "default": "/etc/mime.types"}, - "mime_mappings_overrides": {"type": "map", "description": "Override mappings to convert mime types into the appropriate file extension. Used to override the above file if it assigns weird extensions to any mime types.", "default": { - "text/plain": "txt", - "audio/mpeg": "mp3" - }}, - "min_preview_size": {"type": "number", "description": "The minimum allowed size of generated preview images in pixels.", "default": 1}, - "max_preview_size": {"type": "number", "description": "The maximum allowed size of generated preview images in pixels.", "default": 2048}, - "search_characters_context": {"type": "number", "description": "The number of characters that should be displayed either side of a matching term in the context below each search result.", "default": 200}, - "search_title_matches_weighting": {"type": "number", "description": "The weighting to give to search term matches found in a page's title.", "default": 10}, - "search_tags_matches_weighting": {"type": "number", "description": "The weighting to give to search term matches found in a page's tags.", "default": 3}, - "dynamic_page_suggestion_count": {"type": "number", "description": "The number of dynamic page name suggestions to fetch from the server when typing in the page search box. Note that lowering this number doesn't really improve performance. Set to 0 to disable.", "default": 7 }, - "defaultaction": {"type": "text", "description": "The default action. This action will be performed if no other action is specified. It is recommended you set this to \"view\" - that way the user automatically views the default page (see above).", "default": "view"}, - "updateurl": {"type": "url", "description": "The url from which to fetch updates. Defaults to the master (development) branch. MAKE SURE THAT THIS POINTS TO A *HTTPS* URL, OTHERWISE SOMEONE COULD INJECT A VIRUS INTO YOUR WIKI!", "default": "https://raw.githubusercontent.com/sbrl/pepperminty-wiki/master/index.php"}, - "optimize_pages": {"type": "checkbox", "description": "Whether to optimise all webpages generated.", "default": true}, - "max_recent_changes": {"type": "number", "description": "The maximum number of recent changes to display on the recent changes page.", "default": 512}, - "export_allow_only_admins": {"type": "checkbox", "description": "Whether to only allow adminstrators to export the your wiki as a zip using the page-export module.", "default": false}, - "sessionprefix": {"type": "text", "description": "You shouldn't need to change this. The prefix that should be used in the names of the session variables. Defaults to \"auto\", which automatically generates this field. See the readme for more information.", "default": "auto"}, - "sessionlifetime": { "type": "number", "description": "Again, you shouldn't need to change this under normal circumstances. This setting controls the lifetime of a login session. Defaults to 24 hours, but it may get cut off sooner depending on the underlying PHP session lifetime.", "default": 86400 }, - "css": {"type": "textarea", "description": "A string of css to include. Will be included in the <head> of every page inside a <style> tag. This may also be an absolute url - urls will be referenced via a <link rel='stylesheet' /> tag.", "default": "auto"} -} -GUICONFIG; - -$settingsFilename = "peppermint.json"; - -$guiConfig = json_decode($guiConfig); -$settings = new stdClass(); -if(!file_exists($settingsFilename)) -{ - // Copy the default settings over to the main settings array - foreach ($guiConfig as $key => $value) - $settings->$key = $value->default; - // Generate a random secret - $settings->secret = bin2hex(openssl_random_pseudo_bytes(16)); - file_put_contents("peppermint.json", json_encode($settings, JSON_PRETTY_PRINT)); -} -else - $settings = json_decode(file_get_contents("peppermint.json")); - -if($settings === null) -{ - header("content-type: text/plain"); - exit("Error: Failed to decode the settings file! Does it contain a syntax error?"); -} - -// Fill in any missing properties -$settingsUpgraded = false; -foreach($guiConfig as $key => $propertyData) -{ - if(!isset($settings->$key)) - { - $settings->$key = $propertyData->default; - $settingsUpgraded = true; - } -} -if($settingsUpgraded) - file_put_contents("peppermint.json", json_encode($settings, JSON_PRETTY_PRINT)); - -// Insert the default CSS if requested -$defaultCSS = << span { flex: 1; text-align: center; line-height: 2; display: inline-block; margin: 0; padding: 0.3rem 0.5rem; border-left: 3px solid #442772; border-right: 3px solid #442772; } -nav:not(.nav-more-menu) a { text-decoration: none; font-weight: bolder; color: inherit; } -.nav-divider { color: transparent; } - -.nav-more { position: relative; background-color: #442772; min-width: 10em; } -.nav-more label { cursor: pointer; } -.nav-more-menu { display: none; z-index: 10000; position: absolute; flex-direction: column; top: 2.6rem; right: -0.2rem; background-color: #8a62a7; border-top: 3px solid #442772; border-bottom: 3px solid #442772; } -input[type=checkbox]:checked ~ .nav-more-menu { display: block; box-shadow: 0.4rem 0.4rem 1rem 0 rgba(50, 50, 50, 0.5); } -.nav-more-menu span { min-width: 10rem; } - -.inflexible { flex: none; } -.off-screen { position: absolute; top: -1000px; left: -1000px;} - -input[type=search] { width: 14rem; padding: 0.3rem 0.4rem; font-size: 1rem; color: white; background: rgba(255, 255, 255, 0.4); border: 0; border-radius: 0.3rem; } -input[type=search]::-webkit-input-placeholder { color : rgba(255, 255, 255, 0.75); } -input[type=button], input[type=submit] { cursor: pointer; } - -.sidebar { position: relative; z-index: 100; margin-top: 0.6rem; padding: 1rem 3rem 2rem 0.4rem; background: #9e7eb4; box-shadow: inset -0.6rem 0 0.8rem -0.5rem rgba(50, 50, 50, 0.5); } -.sidebar a { color: #ffa74d; } - -.sidebar ul { position: relative; margin: 0.3rem 0.3rem 0.3rem 1rem; padding: 0.3rem 0.3rem 0.3rem 1rem; list-style-type: none; } -.sidebar li { position: relative; margin: 0.3rem; padding: 0.3rem; } - -.sidebar ul:before { content: ""; position: absolute; top: 0; left: 0; height: 100%; border-left: 2px dashed rgba(50, 50, 50, 0.4); } -.sidebar li:before { content: ""; position: absolute; width: 1rem; top: 0.8rem; left: -1.2rem; border-bottom: 2px dashed rgba(50, 50, 50, 0.4); } - -.preview { text-align: center; } -.preview:hover img, .preview:hover video, .preview:hover audio { --checkerboard-bg: rgba(200, 200, 200, 0.2); max-width: 100%; background-color: #eee; background-image: linear-gradient(45deg, var(--checkerboard-bg) 25%, transparent 25%, transparent 75%, var(--checkerboard-bg) 75%, var(--checkerboard-bg)), linear-gradient(45deg, var(--checkerboard-bg) 25%, transparent 25%, transparent 75%, var(--checkerboard-bg) 75%, var(--checkerboard-bg)); background-size:2em 2em; background-position:0 0, 1em 1em; } -.image-controls ul { list-style-type: none; margin: 5px; padding: 5px; } -.image-controls li { display: inline-block; margin: 5px; padding: 5px; } -.link-display { margin-left: 0.5rem; } - -audio, video, img { max-width: 100%; } -figure:not(.preview) { display: inline-block; } -figure:not(.preview) > :first-child { display: block; } -figcaption { text-align: center; } - -.printable { padding: 2rem; } - -h1 { text-align: center; } -.sitename { margin-top: 5rem; margin-bottom: 3rem; font-size: 2.5rem; } -.logo { max-width: 4rem; max-height: 4rem; vertical-align: middle; } -.logo.small { max-width: 2rem; max-height: 2rem; } -main:not(.printable) { position: relative; z-index: 1000; padding: 2rem 2rem 0.5rem 2rem; background: #faf8fb; box-shadow: 0 0.1rem 1rem 0.3rem rgba(50, 50, 50, 0.5); } - -blockquote { padding-left: 1em; border-left: 0.2em solid #442772; border-radius: 0.2rem; } - -a { cursor: pointer;; } -a.redlink:link { color: rgb(230, 7, 7); } -a.redlink:visited { color: rgb(130, 15, 15); /*#8b1a1a*/ } - -.search-result { position: relative; } -.search-result::before { content: attr(data-result-number); position: relative; top: 3rem; color: rgba(33, 33, 33, 0.3); font-size: 2rem; } -.search-result::after { content: "Rank: " attr(data-rank); position: absolute; top: 3.8rem; right: 0.7rem; color: rgba(50, 50, 50, 0.3); } -.search-result > h2 { margin-left: 3rem; } -.search-context { max-height: 20em; overflow: hidden; } -.search-context::after { content: ""; position: absolute; bottom: 0; width: 100%; height: 3em; display: block; background: linear-gradient(to bottom, transparent, #faf8fb); pointer-events: none; } - -textarea[name=content] { resize: none; } -.fit-text-mirror { position: absolute; left: -10000vw; word-wrap: break-word; white-space: pre-wrap; } -main label:not(.link-display-label) { display: inline-block; min-width: 16rem; } -input[type=text]:not(.link-display), input[type=password], input[type=url], input[type=email], input[type=number], textarea { margin: 0.5rem 0; } -input[type=text], input[type=password], input[type=url], input[type=email], input[type=number], textarea, textarea[name=content] + pre, #search-box { padding: 0.5rem 0.8rem; background: #d5cbf9; border: 0; border-radius: 0.3rem; font-size: 1rem; color: #442772; } -textarea { min-height: 10em; line-height: 1.3em; font-size: 1.25rem; } -textarea, textarea[name=content] + pre, textarea ~ input[type=submit], #search-box { width: calc(100% - 0.3rem); box-sizing: border-box; } -textarea ~ input[type=submit] { margin: 0.5rem 0; padding: 0.5rem; font-weight: bolder; } -.editform input[type=text] { width: calc(100% - 0.3rem); box-sizing: border-box; } -.jump-to-comments { position: relative; top: -2.5em; display: block; text-align: right; pointer-events: none; } -.jump-to-comments > a { pointer-events: all; } - -.file-gallery { margin: 0.5em; padding: 0.5em; list-style-type: none; } -.file-gallery > li { display: inline-block; min-width: attr(data-gallery-width); padding: 1em; text-align: center; } -.file-gallery > li img, .file-gallery > li video, .file-gallery > li audio { display: block; margin: 0 auto; background-color: white; } - -.page-tags-display { margin: 0.5rem 0 0 0; padding: 0; list-style-type: none; } -.page-tags-display li { display: inline-block; margin: 0.5rem; padding: 0.5rem; background: #e2d5eb; white-space: nowrap; } -.page-tags-display li a { color: #fb701a; text-decoration: none; } -.page-tags-display li::before { content: "\\A"; position: relative; top: 0.03rem; left: -0.9rem; width: 0; height: 0; border-top: 0.6rem solid transparent; border-bottom: 0.6rem solid transparent; border-right: 0.5rem solid #e2d5eb; } - -.page-list { list-style-type: none; margin: 0.3rem; padding: 0.3rem; } -.page-list li:not(.header) { margin: 0.3rem; padding: 0.3rem; } -.page-list li .size { margin-left: 0.7rem; color: rgba(30, 30, 30, 0.5); } -.page-list li .editor { display: inline-block; margin: 0 0.5rem; } -.page-list li .tags { margin: 0 1rem; } -.tag-list { list-style-type: none; margin: 0.5rem; padding: 0.5rem; } -.tag-list li { display: inline-block; margin: 1rem; } -.mini-tag { background: #e2d5eb; padding: 0.2rem 0.4rem; color: #fb701a; text-decoration: none; } - -.help-section-header::after { content: "#" attr(id); float: right; color: rgba(0, 0, 0, 0.4); font-size: 0.8rem; font-weight: normal; } - -.stacked-bar { display: flex; } -.stacked-bar-part { break-inside: avoid; white-space: pre; padding: 0.2em 0.3em; } - -.cursor-query { cursor: help; } - -summary { cursor: pointer; } - -.larger { color: rgb(9, 180, 0); } -.smaller, .deletion { color: rgb(207, 28, 17); } -.nochange { color: rgb(132, 123, 199); font-style: italic; } -.significant { font-weight: bolder; font-size: 1.1rem; } -.deletion, .deletion > .editor { text-decoration: line-through; } - -.highlighted-diff { white-space: pre-wrap; } -.diff-added { background-color: rgba(31, 171, 36, 0.6); color: rgba(23, 125, 27, 1); } -.diff-removed { background-color: rgba(255, 96, 96, 0.6); color: rgba(191, 38, 38, 1); } - -.newpage::before { content: "N"; margin: 0 0.3em 0 -1em; font-weight: bolder; text-decoration: underline dotted; } -.upload::before { content: "\\1f845"; margin: 0 0.1em 0 -1.1em; } -.new-comment::before { content: "\\1f4ac"; margin: 0 0.1em 0 -1.1em; } - -.comments { padding: 1em 2em; background: hsl(31, 64%, 85%); box-shadow: 0 0.1rem 1rem 0.3rem rgba(50, 50, 50, 0.5); } -.comments textarea { background: hsl(270, 60%, 86%); } -.comments ::-webkit-input-placeholder { color: hsla(240, 61%, 67%, 0.61); } -.comments .not-logged-in { padding: 0.3em 0.65em; background: hsla(27, 92%, 68%, 0.64); border-radius: 0.2em; font-style: italic; } - -.comment { margin: 1em 0; padding: 0.01em 0; background: hsla(30, 84%, 72%, 0.54); } -.comment-header { padding: 0 1em; } -.comment .name { font-weight: bold; } -.comment-body { padding: 0 1em; } -.comment-footer { padding-left: 1em; } -.comment-footer-item { padding: 0 0.3em; } -.permalink-button { text-decoration: none; } -.comments-list .comments-list .comment { margin: 1em; } - -.reply-box-container.active { padding: 1em; background: hsla(32, 82%, 62%, 0.3); } - -footer { padding: 2rem; } -/* #ffdb6d #36962c hsl(36, 78%, 80%) hsl(262, 92%, 68%, 0.42) */ -THEMECSS; -if($settings->css === "auto") - $settings->css = $defaultCSS; - - + +/* + * Pepperminty Wiki + * ================ + * Inspired by Minty Wiki by am2064 + * Link: https://github.com/am2064/Minty-Wiki + * + * Credits: + * Code by @Starbeamrainbowlabs + * Parsedown - by erusev and others on github from http://parsedown.org/ + * Mathematical Expression rendering + * Code: @con-f-use + * Rendering: MathJax (https://www.mathjax.org/) + * Bug reports: + * #2 - Incorrect closing tag - nibreh + * #8 - Rogue tag - nibreh + */ +$guiConfig = <<<'GUICONFIG' +{ + "sitename": {"type": "text", "description": "Your wiki's name.", "default": "Pepperminty Wiki"}, + "defaultpage": {"type": "text", "description": "The name of the page that will act as the home page for the wiki. This page will be served if you don't specify a page.", "default": "Main Page"}, + "admindetails_name": {"type": "text", "description": "Your name as the wiki administrator.", "default": "Administrator"}, + "admindetails_email": {"type": "email", "description": "Your email address as the wiki administrator. Will be displayed as a support contact address.", "default": "admin@localhost"}, + "favicon": {"type": "url", "description": "A url that points to the favicon you want to use for your wiki. This image By default this is set to a data: url of a Peppermint (Credit: by bluefrog23, source: https://openclipart.org/detail/19571/peppermint-candy-by-bluefrog23)", "default": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAB3VBMVEXhERHbKCjeVVXjb2/kR0fhKirdHBziDg6qAADaHh7qLy/pdXXUNzfMAADYPj7ZPDzUNzfbHx/fERHpamrqMTHgExPdHx/bLCzhLS3fVFTjT0/ibm7kRkbiLi7aKirdISHeFBTqNDTpeHjgERHYJCTVODjYQkLaPj6/AADVOTnpbW3cIyPdFRXcJCThMjLiTU3ibW3fVVXaKyvcERH4ODj+8fH/////fHz+Fxf4KSn0UFD/CAj/AAD/Xl7/wMD/EhL//v70xMT/+Pj/iYn/HBz/g4P/IyP/Kyv/7Oz0QUH/9PT/+vr/ior/Dg7/vr7/aGj/QED/bGz/AQH/ERH/Jib/R0f/goL/0dH/qan/YWH/7e3/Cwv4R0f/MTH/enr/vLz/u7v/cHD/oKD/n5//aWn+9/f/k5P/0tL/trb/QUH/cXH/dHT/wsL/DQ3/p6f/DAz/1dX/XV3/kpL/i4v/Vlb/2Nj/9/f/pKT+7Oz/V1f/iIj/jIz/r6//Zmb/lZX/j4//T0//Dw/4MzP/GBj/+fn/o6P/TEz/xMT/b2//Tk7/OTn/HR3/hIT/ODj/Y2P/CQn/ZGT/6Oj0UlL/Gxv//f3/Bwf/YmL/6+v0w8P/Cgr/tbX0QkL+9fX4Pz/qNzd0dFHLAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfeCxINNSdmw510AAAA5ElEQVQYGQXBzSuDAQCA8eexKXOwmSZepa1JiPJxsJOrCwcnuchBjg4O/gr7D9zk4uAgJzvuMgcTpYxaUZvSm5mUj7TX7ycAqvoLIJBwStVbP0Hom1Z/ejoxrbaR1Jz6nWinbKWttGRgMSSjanPktRY6mB9WtRNTn7Ilh7LxnNpKq2/x5LnBitfz+hx0qxUaxhZ6vwqq9bx6f2XXvuUl9SVQS38NR7cvln3v15tZ9bQpuWDtZN3Lgh5DWJex3Y+z1KrVhw21+CiM74WZo83DiXq0dVBDYNJkFEU7WrwDAZhRtQrwDzwKQbT6GboLAAAAAElFTkSuQmCC"}, + "logo_url": {"type": "url", "description": "A url that points to the site's logo. Leave blank to disable. When enabled the logo will be inserted next to the site name on every page.", "default": "//starbeamrainbowlabs.com/images/logos/peppermint.png"}, + "logo_position": {"type": "text", "description": "The side of the site name at which the logo should be placed.", "default": "left"}, + "show_subpages": {"type": "text", "description": "Whether to show a list of subpages at the bottom of the page.", "default": true}, + "subpages_display_depth": {"type": "text", "description": "The depth to which we should display when listing subpages at the bottom the page.", "default": 3}, + "footer_message": {"type": "textarea", "description": "A message that will appear at the bottom of every page. May contain HTML.", "default": "All content is under this license. Please make sure that you read and understand the license, especially if you are thinking about copying some (or all) of this site's content, as it may restrict you from doing so."}, + "editing_message": {"type": "textarea", "description": "A message that will appear just before the submit button on the editing page. May contain HTML.", "default": "Formatting help (Markdown Cheatsheet)
    \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"}, + "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 sha256 (or sha3 if you have that option turned on)", "default": { + "admin": { + "email": "admin@somewhere.com", + "password": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + }, + "user": { + "email": "example@example.net", + "password": "873ac9ffea4dd04fa719e8920cd6938f0c23cd678af330939cff53c3d2855f34" + } + }}, + "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": "⚙ " }, + "use_sha3": {"type": "checkbox", "description": "Whether to use the new sha3 hashing algorithm for passwords etc.", "default": false }, + "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.
    ", "default": [ + "user-status", + [ + "Home", + "index.php" + ], + "search", + [ + "Read", + "index.php?page={page}" + ], + [ + "Edit", + "index.php?action=edit&page={page}" + ], + [ + "All Pages", + "index.php?action=list" + ], + "menu" + ]}, + "nav_links_extra": {"type": "nav", "description": "An array of additional links in the above format that will be shown under \"More\" subsection.", "default": [ + [ + "🕓 Page History", + "?action=history&page={page}" + ], + [ + "🎫 All Tags", + "index.php?action=list-tags" + ], + [ + "⇝ Random Page", + "?action=random" + ], + [ + "Recent changes", + "?action=recent-changes" + ], + [ + "🡅 Upload", + "index.php?action=upload" + ], + [ + "⌧ ◆Delete", + "index.php?action=delete&page={page}" + ], + [ + "⎘ ◆Move", + "index.php?action=move&page={page}" + ], + [ + "🔐 ◆Toggle Protection", + "index.php?action=protect&page={page}" + ], + [ + "⚙ ◆Edit master settings", + "index.php?action=configure" + ] + ]}, + "nav_links_bottom": {"type": "nav", "description": "An array of links in the above format that will be shown at the bottom of the page.", "default": [ + [ + "🖶 Printable version", + "index.php?action=view&mode=printable&page={page}" + ], + [ + "Credits", + "index.php?action=credits" + ], + [ + "Help", + "index.php?action=help" + ] + ]}, + "comment_max_length": {"type": "number", "description": "The maximum allowed length, in characters, for comments", "default": 5000 }, + "comment_min_length": {"type": "number", "description": "The minimum allowed length, in characters, for comments", "default": 10 }, + "comment_time_icon": {"type": "text", "description": "The icon to show next to the time that a comment was posted.", "default": "🕗" }, + "upload_enabled": {"type": "checkbox", "description": "Whether to allow uploads to the server.", "default": true}, + "upload_allowed_file_types": {"type": "array", "description": "An array of mime types that are allowed to be uploaded.", "default": [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", + "video/mp4", + "video/webm", + "audio/mp4", + "audio/mpeg" + ]}, + "preview_file_type": {"type": "text", "description": "The default file type for previews.", "default": "image/png"}, + "default_preview_size": {"type": "number", "description": "The default size of preview images in pixels.", "default": 640}, + "mime_extension_mappings_location": {"type": "text", "description": "The location of a file that maps mime types onto file extensions and vice versa. Used to generate the file extension for an uploaded file. See the configuration guide for windows instructions.", "default": "/etc/mime.types"}, + "mime_mappings_overrides": {"type": "map", "description": "Override mappings to convert mime types into the appropriate file extension. Used to override the above file if it assigns weird extensions to any mime types.", "default": { + "text/plain": "txt", + "audio/mpeg": "mp3" + }}, + "min_preview_size": {"type": "number", "description": "The minimum allowed size of generated preview images in pixels.", "default": 1}, + "max_preview_size": {"type": "number", "description": "The maximum allowed size of generated preview images in pixels.", "default": 2048}, + "search_characters_context": {"type": "number", "description": "The number of characters that should be displayed either side of a matching term in the context below each search result.", "default": 200}, + "search_title_matches_weighting": {"type": "number", "description": "The weighting to give to search term matches found in a page's title.", "default": 10}, + "search_tags_matches_weighting": {"type": "number", "description": "The weighting to give to search term matches found in a page's tags.", "default": 3}, + "dynamic_page_suggestion_count": {"type": "number", "description": "The number of dynamic page name suggestions to fetch from the server when typing in the page search box. Note that lowering this number doesn't really improve performance. Set to 0 to disable.", "default": 7 }, + "defaultaction": {"type": "text", "description": "The default action. This action will be performed if no other action is specified. It is recommended you set this to \"view\" - that way the user automatically views the default page (see above).", "default": "view"}, + "updateurl": {"type": "url", "description": "The url from which to fetch updates. Defaults to the master (development) branch. MAKE SURE THAT THIS POINTS TO A *HTTPS* URL, OTHERWISE SOMEONE COULD INJECT A VIRUS INTO YOUR WIKI!", "default": "https://raw.githubusercontent.com/sbrl/pepperminty-wiki/master/index.php"}, + "optimize_pages": {"type": "checkbox", "description": "Whether to optimise all webpages generated.", "default": true}, + "max_recent_changes": {"type": "number", "description": "The maximum number of recent changes to display on the recent changes page.", "default": 512}, + "export_allow_only_admins": {"type": "checkbox", "description": "Whether to only allow adminstrators to export the your wiki as a zip using the page-export module.", "default": false}, + "sessionprefix": {"type": "text", "description": "You shouldn't need to change this. The prefix that should be used in the names of the session variables. Defaults to \"auto\", which automatically generates this field. See the readme for more information.", "default": "auto"}, + "sessionlifetime": { "type": "number", "description": "Again, you shouldn't need to change this under normal circumstances. This setting controls the lifetime of a login session. Defaults to 24 hours, but it may get cut off sooner depending on the underlying PHP session lifetime.", "default": 86400 }, + "css": {"type": "textarea", "description": "A string of css to include. Will be included in the <head> of every page inside a <style> tag. This may also be an absolute url - urls will be referenced via a <link rel='stylesheet' /> tag.", "default": "auto"} +} +GUICONFIG; + +$settingsFilename = "peppermint.json"; + +$guiConfig = json_decode($guiConfig); +$settings = new stdClass(); +if(!file_exists($settingsFilename)) +{ + // Copy the default settings over to the main settings array + foreach ($guiConfig as $key => $value) + $settings->$key = $value->default; + // Generate a random secret + $settings->secret = bin2hex(openssl_random_pseudo_bytes(16)); + file_put_contents("peppermint.json", json_encode($settings, JSON_PRETTY_PRINT)); +} +else + $settings = json_decode(file_get_contents("peppermint.json")); + +if($settings === null) +{ + header("content-type: text/plain"); + exit("Error: Failed to decode the settings file! Does it contain a syntax error?"); +} + +// Fill in any missing properties +$settingsUpgraded = false; +foreach($guiConfig as $key => $propertyData) +{ + if(!isset($settings->$key)) + { + $settings->$key = $propertyData->default; + $settingsUpgraded = true; + } +} +if($settingsUpgraded) + file_put_contents("peppermint.json", json_encode($settings, JSON_PRETTY_PRINT)); + +// Insert the default CSS if requested +$defaultCSS = << span { flex: 1; text-align: center; line-height: 2; display: inline-block; margin: 0; padding: 0.3rem 0.5rem; border-left: 3px solid #442772; border-right: 3px solid #442772; } +nav:not(.nav-more-menu) a { text-decoration: none; font-weight: bolder; color: inherit; } +.nav-divider { color: transparent; } + +.nav-more { position: relative; background-color: #442772; min-width: 10em; } +.nav-more label { cursor: pointer; } +.nav-more-menu { display: none; z-index: 10000; position: absolute; flex-direction: column; top: 2.6rem; right: -0.2rem; background-color: #8a62a7; border-top: 3px solid #442772; border-bottom: 3px solid #442772; } +input[type=checkbox]:checked ~ .nav-more-menu { display: block; box-shadow: 0.4rem 0.4rem 1rem 0 rgba(50, 50, 50, 0.5); } +.nav-more-menu span { min-width: 10rem; } + +.inflexible { flex: none; } +.off-screen { position: absolute; top: -1000px; left: -1000px;} + +input[type=search] { width: 14rem; padding: 0.3rem 0.4rem; font-size: 1rem; color: white; background: rgba(255, 255, 255, 0.4); border: 0; border-radius: 0.3rem; } +input[type=search]::-webkit-input-placeholder { color : rgba(255, 255, 255, 0.75); } +input[type=button], input[type=submit] { cursor: pointer; } + +.sidebar { position: relative; z-index: 100; margin-top: 0.6rem; padding: 1rem 3rem 2rem 0.4rem; background: #9e7eb4; box-shadow: inset -0.6rem 0 0.8rem -0.5rem rgba(50, 50, 50, 0.5); } +.sidebar a { color: #ffa74d; } + +.sidebar ul { position: relative; margin: 0.3rem 0.3rem 0.3rem 1rem; padding: 0.3rem 0.3rem 0.3rem 1rem; list-style-type: none; } +.sidebar li { position: relative; margin: 0.3rem; padding: 0.3rem; } + +.sidebar ul:before { content: ""; position: absolute; top: 0; left: 0; height: 100%; border-left: 2px dashed rgba(50, 50, 50, 0.4); } +.sidebar li:before { content: ""; position: absolute; width: 1rem; top: 0.8rem; left: -1.2rem; border-bottom: 2px dashed rgba(50, 50, 50, 0.4); } + +.preview { text-align: center; } +.preview:hover img, .preview:hover video, .preview:hover audio { --checkerboard-bg: rgba(200, 200, 200, 0.2); max-width: 100%; background-color: #eee; background-image: linear-gradient(45deg, var(--checkerboard-bg) 25%, transparent 25%, transparent 75%, var(--checkerboard-bg) 75%, var(--checkerboard-bg)), linear-gradient(45deg, var(--checkerboard-bg) 25%, transparent 25%, transparent 75%, var(--checkerboard-bg) 75%, var(--checkerboard-bg)); background-size:2em 2em; background-position:0 0, 1em 1em; } +.image-controls ul { list-style-type: none; margin: 5px; padding: 5px; } +.image-controls li { display: inline-block; margin: 5px; padding: 5px; } +.link-display { margin-left: 0.5rem; } + +audio, video, img { max-width: 100%; } +figure:not(.preview) { display: inline-block; } +figure:not(.preview) > :first-child { display: block; } +figcaption { text-align: center; } + +.printable { padding: 2rem; } + +h1 { text-align: center; } +.sitename { margin-top: 5rem; margin-bottom: 3rem; font-size: 2.5rem; } +.logo { max-width: 4rem; max-height: 4rem; vertical-align: middle; } +.logo.small { max-width: 2rem; max-height: 2rem; } +main:not(.printable) { position: relative; z-index: 1000; padding: 2rem 2rem 0.5rem 2rem; background: #faf8fb; box-shadow: 0 0.1rem 1rem 0.3rem rgba(50, 50, 50, 0.5); } + +blockquote { padding-left: 1em; border-left: 0.2em solid #442772; border-radius: 0.2rem; } + +a { cursor: pointer;; } +a.redlink:link { color: rgb(230, 7, 7); } +a.redlink:visited { color: rgb(130, 15, 15); /*#8b1a1a*/ } + +.search-result { position: relative; } +.search-result::before { content: attr(data-result-number); position: relative; top: 3rem; color: rgba(33, 33, 33, 0.3); font-size: 2rem; } +.search-result::after { content: "Rank: " attr(data-rank); position: absolute; top: 3.8rem; right: 0.7rem; color: rgba(50, 50, 50, 0.3); } +.search-result > h2 { margin-left: 3rem; } +.search-context { max-height: 20em; overflow: hidden; } +.search-context::after { content: ""; position: absolute; bottom: 0; width: 100%; height: 3em; display: block; background: linear-gradient(to bottom, transparent, #faf8fb); pointer-events: none; } + +textarea[name=content] { resize: none; } +.fit-text-mirror { position: absolute; left: -10000vw; word-wrap: break-word; white-space: pre-wrap; } +main label:not(.link-display-label) { display: inline-block; min-width: 16rem; } +input[type=text]:not(.link-display), input[type=password], input[type=url], input[type=email], input[type=number], textarea { margin: 0.5rem 0; } +input[type=text], input[type=password], input[type=url], input[type=email], input[type=number], textarea, textarea[name=content] + pre, #search-box { padding: 0.5rem 0.8rem; background: #d5cbf9; border: 0; border-radius: 0.3rem; font-size: 1rem; color: #442772; } +textarea { min-height: 10em; line-height: 1.3em; font-size: 1.25rem; } +textarea, textarea[name=content] + pre, textarea ~ input[type=submit], #search-box { width: calc(100% - 0.3rem); box-sizing: border-box; } +textarea ~ input[type=submit] { margin: 0.5rem 0; padding: 0.5rem; font-weight: bolder; } +.editform input[type=text] { width: calc(100% - 0.3rem); box-sizing: border-box; } +.jump-to-comments { position: relative; top: -2.5em; display: block; text-align: right; pointer-events: none; } +.jump-to-comments > a { pointer-events: all; } + +.file-gallery { margin: 0.5em; padding: 0.5em; list-style-type: none; } +.file-gallery > li { display: inline-block; min-width: attr(data-gallery-width); padding: 1em; text-align: center; } +.file-gallery > li img, .file-gallery > li video, .file-gallery > li audio { display: block; margin: 0 auto; background-color: white; } + +.page-tags-display { margin: 0.5rem 0 0 0; padding: 0; list-style-type: none; } +.page-tags-display li { display: inline-block; margin: 0.5rem; padding: 0.5rem; background: #e2d5eb; white-space: nowrap; } +.page-tags-display li a { color: #fb701a; text-decoration: none; } +.page-tags-display li::before { content: "\\A"; position: relative; top: 0.03rem; left: -0.9rem; width: 0; height: 0; border-top: 0.6rem solid transparent; border-bottom: 0.6rem solid transparent; border-right: 0.5rem solid #e2d5eb; } + +.page-list { list-style-type: none; margin: 0.3rem; padding: 0.3rem; } +.page-list li:not(.header) { margin: 0.3rem; padding: 0.3rem; } +.page-list li .size { margin-left: 0.7rem; color: rgba(30, 30, 30, 0.5); } +.page-list li .editor { display: inline-block; margin: 0 0.5rem; } +.page-list li .tags { margin: 0 1rem; } +.tag-list { list-style-type: none; margin: 0.5rem; padding: 0.5rem; } +.tag-list li { display: inline-block; margin: 1rem; } +.mini-tag { background: #e2d5eb; padding: 0.2rem 0.4rem; color: #fb701a; text-decoration: none; } + +.help-section-header::after { content: "#" attr(id); float: right; color: rgba(0, 0, 0, 0.4); font-size: 0.8rem; font-weight: normal; } + +.stacked-bar { display: flex; } +.stacked-bar-part { break-inside: avoid; white-space: pre; padding: 0.2em 0.3em; } + +.cursor-query { cursor: help; } + +summary { cursor: pointer; } + +.larger { color: rgb(9, 180, 0); } +.smaller, .deletion { color: rgb(207, 28, 17); } +.nochange { color: rgb(132, 123, 199); font-style: italic; } +.significant { font-weight: bolder; font-size: 1.1rem; } +.deletion, .deletion > .editor { text-decoration: line-through; } + +.highlighted-diff { white-space: pre-wrap; } +.diff-added { background-color: rgba(31, 171, 36, 0.6); color: rgba(23, 125, 27, 1); } +.diff-removed { background-color: rgba(255, 96, 96, 0.6); color: rgba(191, 38, 38, 1); } + +.newpage::before { content: "N"; margin: 0 0.3em 0 -1em; font-weight: bolder; text-decoration: underline dotted; } +.upload::before { content: "\\1f845"; margin: 0 0.1em 0 -1.1em; } +.new-comment::before { content: "\\1f4ac"; margin: 0 0.1em 0 -1.1em; } + +.comments { padding: 1em 2em; background: hsl(31, 64%, 85%); box-shadow: 0 0.1rem 1rem 0.3rem rgba(50, 50, 50, 0.5); } +.comments textarea { background: hsl(270, 60%, 86%); } +.comments ::-webkit-input-placeholder { color: hsla(240, 61%, 67%, 0.61); } +.comments .not-logged-in { padding: 0.3em 0.65em; background: hsla(27, 92%, 68%, 0.64); border-radius: 0.2em; font-style: italic; } + +.comment { margin: 1em 0; padding: 0.01em 0; background: hsla(30, 84%, 72%, 0.54); } +.comment-header { padding: 0 1em; } +.comment .name { font-weight: bold; } +.comment-body { padding: 0 1em; } +.comment-footer { padding-left: 1em; } +.comment-footer-item { padding: 0 0.3em; } +.permalink-button { text-decoration: none; } +.comments-list .comments-list .comment { margin: 1em; } + +.reply-box-container.active { padding: 1em; background: hsla(32, 82%, 62%, 0.3); } + +footer { padding: 2rem; } +/* #ffdb6d #36962c hsl(36, 78%, 80%) hsl(262, 92%, 68%, 0.42) */ +THEMECSS; +if($settings->css === "auto") + $settings->css = $defaultCSS; + + /////////////////////////////////////////////////////////////////////////////////////////////// @@ -1830,141 +1830,141 @@ register_module([ ]); - - -register_module([ - "name" => "Page protection", - "version" => "0.2", - "author" => "Starbeamrainbowlabs", - "description" => "Exposes Pepperminty Wiki's new page protection mechanism and makes the protect button in the 'More...' menu on the top bar work.", - "id" => "action-protect", - "code" => function() { - /** - * @api {get} ?action=protect&page={pageName} Toggle the protection of a page. - * @apiName Protect - * @apiGroup Page - * @apiPermission Moderator - * - * @apiParam {string} page The page name to toggle the protection of. - */ - - /* - * ██████ ██████ ██████ ████████ ███████ ██████ ████████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██████ ██ ██ ██ █████ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██████ ██ ███████ ██████ ██ - */ - add_action("protect", function() { - global $env, $pageindex, $paths, $settings; - - // Make sure that the user is logged in as an admin / mod. - if($env->is_admin) - { - // They check out ok, toggle the page's protection. - $page = $env->page; - - if(!isset($pageindex->$page->protect)) - { - $pageindex->$page->protect = true; - } - else if($pageindex->$page->protect === true) - { - $pageindex->$page->protect = false; - } - else if($pageindex->$page->protect === false) - { - $pageindex->$page->protect = true; - } - - // Save the pageindex - file_put_contents($paths->pageindex, json_encode($pageindex, JSON_PRETTY_PRINT)); - - $state = ($pageindex->$page->protect ? "enabled" : "disabled"); - $title = "Page protection $state."; - exit(page_renderer::render_main($title, "

    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.1", - "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 Redirects to a random page. - * @apiName RawSource - * @apiGroup Page - * @apiPermission Anonymous - */ - - add_action("random", function() { - global $pageindex; - - $pageNames = array_keys(get_object_vars($pageindex)); - $randomPageName = $pageNames[array_rand($pageNames)]; - - http_response_code(307); - header("location: ?page=" . rawurlencode($randomPageName)); - }); - - 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 $env; - - header("content-type: text/markdown"); - 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.

    "); - } -]); - - - + + +register_module([ + "name" => "Page protection", + "version" => "0.2", + "author" => "Starbeamrainbowlabs", + "description" => "Exposes Pepperminty Wiki's new page protection mechanism and makes the protect button in the 'More...' menu on the top bar work.", + "id" => "action-protect", + "code" => function() { + /** + * @api {get} ?action=protect&page={pageName} Toggle the protection of a page. + * @apiName Protect + * @apiGroup Page + * @apiPermission Moderator + * + * @apiParam {string} page The page name to toggle the protection of. + */ + + /* + * ██████ ██████ ██████ ████████ ███████ ██████ ████████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██████ ██ ██ ██ █████ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██████ ██ ███████ ██████ ██ + */ + add_action("protect", function() { + global $env, $pageindex, $paths, $settings; + + // Make sure that the user is logged in as an admin / mod. + if($env->is_admin) + { + // They check out ok, toggle the page's protection. + $page = $env->page; + + if(!isset($pageindex->$page->protect)) + { + $pageindex->$page->protect = true; + } + else if($pageindex->$page->protect === true) + { + $pageindex->$page->protect = false; + } + else if($pageindex->$page->protect === false) + { + $pageindex->$page->protect = true; + } + + // Save the pageindex + file_put_contents($paths->pageindex, json_encode($pageindex, JSON_PRETTY_PRINT)); + + $state = ($pageindex->$page->protect ? "enabled" : "disabled"); + $title = "Page protection $state."; + exit(page_renderer::render_main($title, "

    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.1", + "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 Redirects to a random page. + * @apiName RawSource + * @apiGroup Page + * @apiPermission Anonymous + */ + + add_action("random", function() { + global $pageindex; + + $pageNames = array_keys(get_object_vars($pageindex)); + $randomPageName = $pageNames[array_rand($pageNames)]; + + http_response_code(307); + header("location: ?page=" . rawurlencode($randomPageName)); + }); + + 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 $env; + + header("content-type: text/markdown"); + 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.

    "); + } +]); + + + register_module([ "name" => "Sidebar", @@ -2068,9 +2068,6 @@ function render_sidebar($pageindex, $root_pagename = "") if($pagename == $root_pagename) continue; - // If the page already appears on the sidebar, skip it - if(preg_match("/>$pagename<\a>/m",$result)===1) - continue; // If the part of the current pagename that comes after the root // pagename has a slash in it, skip it as it is a sub-sub page. @@ -2087,508 +2084,508 @@ function render_sidebar($pageindex, $root_pagename = "") } - - -register_module([ - "name" => "Page Comments", - "version" => "0.2", - "author" => "Starbeamrainbowlabs", - "description" => "Adds threaded comments to the bottom of every page.", - "id" => "feature-comments", - "code" => function() { - global $env; - - /** - * @api {post} ?action=comment Comment on a page - * @apiName Comment - * @apiGroup Comment - * @apiPermission User - * @apiDescription Posts a comment on a page, optionally in reply to another comment. Currently, comments must be made by a logged-in user. - * - * @apiParam {string} message The comment text. Supports the same syntax that the renderer of the main page supports. The default is extended markdown - see the help page of the specific wiki for more information. - * @apiParam {string} replyto Optional. If specified the comment will be posted in reply to the comment with the specified id. - * - * - * @apiError CommentNotFound The comment to reply to was not found. - */ - - /* - * ██████ ██████ ███ ███ ███ ███ ███████ ███ ██ ████████ - * ██ ██ ██ ████ ████ ████ ████ ██ ████ ██ ██ - * ██ ██ ██ ██ ████ ██ ██ ████ ██ █████ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██████ ██ ██ ██ ██ ███████ ██ ████ ██ - */ - add_action("comment", function() { - global $settings, $env; - - $reply_to = $_POST["replyto"] ?? null; - $message = $_POST["message"] ?? ""; - - if(!$env->is_logged_in) { - http_response_code(401); - exit(page_renderer::render_main("Error posting comment - $settings->sitename", "

    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.

    ")); - }); - - if($env->action == "view") { - page_renderer::register_part_preprocessor(function(&$parts) { - global $env; - $comments_filename = get_comment_filename($env->page); - $comments_data = file_exists($comments_filename) ? json_decode(file_get_contents($comments_filename)) : []; - - - $comments_html = "\n"; - - $to_comments_link = ""; - - $parts["{extra}"] = $comments_html . $parts["{extra}"]; - - $parts["{content}"] = str_replace_once("", "\n$to_comments_link", $parts["{content}"]); - }); - - $reply_js_snippet = <<<'REPLYJS' -/////////////////////////////////// -///////// Commenting Form ///////// -/////////////////////////////////// -window.addEventListener("load", function(event) { - var replyButtons = document.querySelectorAll(".reply-button"); - for(let i = 0; i < replyButtons.length; i++) { - replyButtons[i].addEventListener("click", display_reply_form); - replyButtons[i].addEventListener("touchend", display_reply_form); - } -}); - -function display_reply_form(event) -{ - // Deep-clone the comment form - var replyForm = document.querySelector(".comment-reply-form").cloneNode(true); - replyForm.classList.add("nested"); - // Set the comment we're replying to - replyForm.querySelector("[name=replyto]").value = event.target.parentElement.parentElement.parentElement.dataset.commentId; - // Display the newly-cloned commenting form - var replyBoxContiner = event.target.parentElement.parentElement.parentElement.querySelector(".reply-box-container"); - replyBoxContiner.classList.add("active"); - replyBoxContiner.appendChild(replyForm); - // Hide the reply button so it can't be pressed more than once - that could - // be awkward :P - event.target.parentElement.removeChild(event.target); -} - -REPLYJS; - page_renderer::AddJSSnippet($reply_js_snippet); - - } - } -]); - -/** - * Given a page name, returns the absolute file path in which that page's - * comments are stored. - * @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. - * @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. - * @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; -} - -/** - * 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. - * @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. - * @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; - - if(count($comments_data) == 0) { - if($depth == 0) - return "

    No comments here! Start the conversation above.

    "; - else - return ""; - } - - $result = "
    "; - - //$comments_data = array_reverse($comments_data); - for($i = count($comments_data) - 1; $i >= 0; $i--) { - $comment = $comments_data[$i]; - - $result .= "\t
    \n"; - $result .= "\t

    $comment->username said:

    "; - $result .= "\t
    \n"; - $result .= "\t\t" . parse_page_source($comment->message); - $result .= "\t
    \n"; - $result .= "\t
    \n"; - $result .= "\t\n"; - $result .= "\t" . render_comments($comment->replies, $depth + 1) . "\n"; - $result .= "\t
    "; - } - $result .= "
    "; - - return $result; -} - - - - -register_module([ - "name" => "Settings GUI", - "version" => "0.1.1", - "author" => "Starbeamrainbowlabs", - "description" => "The module everyone has been waiting for! Adds a web based gui that lets mods change the wiki settings.", - "id" => "feature-guiconfig", - "code" => function() { - global $settings; - /** - * @api {get} ?action=configure Get a page to change the global wiki settings - * @apiName ConfigureSettings - * @apiGroup Utility - * @apiPermission Moderator - */ - - /* - * ██████ ██████ ███ ██ ███████ ██ ██████ ██ ██ ██████ ███████ - * ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ █████ ██ ██ ███ ██ ██ ██████ █████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██████ ██ ████ ██ ██ ██████ ██████ ██ ██ ███████ - */ - add_action("configure", function() { - global $settings, $env, $guiConfig; - - if(!$env->is_admin) - { - $errorMessage = "

    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 = "

    Master Control Panel

    \n"; - $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 .= "

    Mouse over the name of each setting to see a description of what it does.

    \n"; - $content .= "
    \n"; - - foreach($guiConfig as $configKey => $configData) - { - // Don't display the site secret~! - // Apparently it got lost in translation, but I'll be re-adding - // it again at some point I'm sure - so support for it is - // included here. - if($configKey == "sitesecret") continue; - - $reverse = false; - $inputControl = ""; - $label = ""; - switch($configData->type) - { - case "url": - case "email": - case "number": - case "text": - $inputControl = ""; - break; - case "textarea": - $inputControl = ""; - break; - case "checkbox": - $reverse = true; - $inputControl = "$configKey ? " checked" : "") . " />"; - break; - default: - $label = ""; - $inputControl = "

    Sorry! The $configKey setting isn't editable yet through the gui. Please try editing peppermint.json for the time being.

    "; - break; - } - - $content .= "
    \n\t"; - $content .= $reverse ? "$inputControl\n\t$label" : "$label\n\t$inputControl"; - $content .= "\n
    \n"; - } - - $content .= ""; - $content .= "
    \n"; - - exit(page_renderer::render_main("Master Control Panel - $settings->sitename", $content)); - }); - - /** - * @api {post} ?action=configure-save Save changes to the global wiki settings - * @apiName ConfigureSettings - * @apiGroup Utility - * @apiPermission Moderator - */ - - /* - * ██████ ██████ ███ ██ ███████ ██ ██████ ██ ██ ██████ ███████ - * ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ █████ ██ ██ ███ ██ ██ ██████ █████ █████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██████ ██ ████ ██ ██ ██████ ██████ ██ ██ ███████ - * ███████ █████ ██ ██ ███████ - * ██ ██ ██ ██ ██ ██ - * ███████ ███████ ██ ██ █████ - * ██ ██ ██ ██ ██ ██ - * ███████ ██ ██ ████ ███████ - */ - - - add_action("configure-save", function () { - global $env, $settings, $paths, $defaultCSS; - - // If the user isn't an admin, then the regular configuration page will display an appropriate error - if(!$env->is_admin) - { - http_response_code(307); - header("location: ?action=configure"); - exit(); - } - - // Build a new settings object - $newSettings = new stdClass(); - foreach($settings as $configKey => $rawValue) - { - $configValue = $rawValue; - if(isset($_POST[$configKey])) - { - $decodedConfigValue = json_decode($_POST[$configKey]); - if(json_last_error() === JSON_ERROR_NONE) - $configValue = $decodedConfigValue; - else - $configValue = $_POST[$configKey]; - - // Convert boolean settings to a boolean, since POST - // parameters don't decode correctly. - if(is_bool($settings->$configKey)) - $configValue = in_array($configValue, [ 1, "on"], true) ? true : false; - - // If the CSS hasn't changed, then we can replace it with - // 'auto' - this will ensure that upon update the new - // default CSS will be used. Also make sure we ignore line - // ending nonsense & differences here, since they really - // don't matter - if($configKey === "css" && str_replace("\r\n", "\n", $defaultCSS) === str_replace("\r\n", "\n", $configValue)) - $configValue = "auto"; - } - - $newSettings->$configKey = $configValue; - } - - // Take a backup of the current settings file - rename($paths->settings_file, "$paths->settings_file.bak"); - // Save the new settings file - file_put_contents($paths->settings_file, json_encode($newSettings, JSON_PRETTY_PRINT)); - - $content = "

    Master settings updated sucessfully

    \n"; - $content .= "

    $settings->sitename's master settings file has been updated successfully. A backup of the original settings has been created under the name peppermint.json.bak, just in case. You can go back and continue editing the master settings file, or you can go to the " . htmlentities($settings->defaultpage) . ".

    \n"; - $content .= "

    For reference, the newly generated master settings file is as follows:

    \n"; - $content .= "\n"; - exit(page_renderer::render_main("Master Settings Updated - $settings->sitename", $content)); - }); - - 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.

    "); - } -]); - - - + + +register_module([ + "name" => "Page Comments", + "version" => "0.2", + "author" => "Starbeamrainbowlabs", + "description" => "Adds threaded comments to the bottom of every page.", + "id" => "feature-comments", + "code" => function() { + global $env; + + /** + * @api {post} ?action=comment Comment on a page + * @apiName Comment + * @apiGroup Comment + * @apiPermission User + * @apiDescription Posts a comment on a page, optionally in reply to another comment. Currently, comments must be made by a logged-in user. + * + * @apiParam {string} message The comment text. Supports the same syntax that the renderer of the main page supports. The default is extended markdown - see the help page of the specific wiki for more information. + * @apiParam {string} replyto Optional. If specified the comment will be posted in reply to the comment with the specified id. + * + * + * @apiError CommentNotFound The comment to reply to was not found. + */ + + /* + * ██████ ██████ ███ ███ ███ ███ ███████ ███ ██ ████████ + * ██ ██ ██ ████ ████ ████ ████ ██ ████ ██ ██ + * ██ ██ ██ ██ ████ ██ ██ ████ ██ █████ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██████ ██ ██ ██ ██ ███████ ██ ████ ██ + */ + add_action("comment", function() { + global $settings, $env; + + $reply_to = $_POST["replyto"] ?? null; + $message = $_POST["message"] ?? ""; + + if(!$env->is_logged_in) { + http_response_code(401); + exit(page_renderer::render_main("Error posting comment - $settings->sitename", "

    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.

    ")); + }); + + if($env->action == "view") { + page_renderer::register_part_preprocessor(function(&$parts) { + global $env; + $comments_filename = get_comment_filename($env->page); + $comments_data = file_exists($comments_filename) ? json_decode(file_get_contents($comments_filename)) : []; + + + $comments_html = "\n"; + + $to_comments_link = ""; + + $parts["{extra}"] = $comments_html . $parts["{extra}"]; + + $parts["{content}"] = str_replace_once("", "\n$to_comments_link", $parts["{content}"]); + }); + + $reply_js_snippet = <<<'REPLYJS' +/////////////////////////////////// +///////// Commenting Form ///////// +/////////////////////////////////// +window.addEventListener("load", function(event) { + var replyButtons = document.querySelectorAll(".reply-button"); + for(let i = 0; i < replyButtons.length; i++) { + replyButtons[i].addEventListener("click", display_reply_form); + replyButtons[i].addEventListener("touchend", display_reply_form); + } +}); + +function display_reply_form(event) +{ + // Deep-clone the comment form + var replyForm = document.querySelector(".comment-reply-form").cloneNode(true); + replyForm.classList.add("nested"); + // Set the comment we're replying to + replyForm.querySelector("[name=replyto]").value = event.target.parentElement.parentElement.parentElement.dataset.commentId; + // Display the newly-cloned commenting form + var replyBoxContiner = event.target.parentElement.parentElement.parentElement.querySelector(".reply-box-container"); + replyBoxContiner.classList.add("active"); + replyBoxContiner.appendChild(replyForm); + // Hide the reply button so it can't be pressed more than once - that could + // be awkward :P + event.target.parentElement.removeChild(event.target); +} + +REPLYJS; + page_renderer::AddJSSnippet($reply_js_snippet); + + } + } +]); + +/** + * Given a page name, returns the absolute file path in which that page's + * comments are stored. + * @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. + * @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. + * @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; +} + +/** + * 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. + * @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. + * @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; + + if(count($comments_data) == 0) { + if($depth == 0) + return "

    No comments here! Start the conversation above.

    "; + else + return ""; + } + + $result = "
    "; + + //$comments_data = array_reverse($comments_data); + for($i = count($comments_data) - 1; $i >= 0; $i--) { + $comment = $comments_data[$i]; + + $result .= "\t
    \n"; + $result .= "\t

    $comment->username said:

    "; + $result .= "\t
    \n"; + $result .= "\t\t" . parse_page_source($comment->message); + $result .= "\t
    \n"; + $result .= "\t
    \n"; + $result .= "\t\n"; + $result .= "\t" . render_comments($comment->replies, $depth + 1) . "\n"; + $result .= "\t
    "; + } + $result .= "
    "; + + return $result; +} + + + + +register_module([ + "name" => "Settings GUI", + "version" => "0.1.1", + "author" => "Starbeamrainbowlabs", + "description" => "The module everyone has been waiting for! Adds a web based gui that lets mods change the wiki settings.", + "id" => "feature-guiconfig", + "code" => function() { + global $settings; + /** + * @api {get} ?action=configure Get a page to change the global wiki settings + * @apiName ConfigureSettings + * @apiGroup Utility + * @apiPermission Moderator + */ + + /* + * ██████ ██████ ███ ██ ███████ ██ ██████ ██ ██ ██████ ███████ + * ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ █████ ██ ██ ███ ██ ██ ██████ █████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██████ ██ ████ ██ ██ ██████ ██████ ██ ██ ███████ + */ + add_action("configure", function() { + global $settings, $env, $guiConfig; + + if(!$env->is_admin) + { + $errorMessage = "

    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 = "

    Master Control Panel

    \n"; + $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 .= "

    Mouse over the name of each setting to see a description of what it does.

    \n"; + $content .= "
    \n"; + + foreach($guiConfig as $configKey => $configData) + { + // Don't display the site secret~! + // Apparently it got lost in translation, but I'll be re-adding + // it again at some point I'm sure - so support for it is + // included here. + if($configKey == "sitesecret") continue; + + $reverse = false; + $inputControl = ""; + $label = ""; + switch($configData->type) + { + case "url": + case "email": + case "number": + case "text": + $inputControl = ""; + break; + case "textarea": + $inputControl = ""; + break; + case "checkbox": + $reverse = true; + $inputControl = "$configKey ? " checked" : "") . " />"; + break; + default: + $label = ""; + $inputControl = "

    Sorry! The $configKey setting isn't editable yet through the gui. Please try editing peppermint.json for the time being.

    "; + break; + } + + $content .= "
    \n\t"; + $content .= $reverse ? "$inputControl\n\t$label" : "$label\n\t$inputControl"; + $content .= "\n
    \n"; + } + + $content .= ""; + $content .= "
    \n"; + + exit(page_renderer::render_main("Master Control Panel - $settings->sitename", $content)); + }); + + /** + * @api {post} ?action=configure-save Save changes to the global wiki settings + * @apiName ConfigureSettings + * @apiGroup Utility + * @apiPermission Moderator + */ + + /* + * ██████ ██████ ███ ██ ███████ ██ ██████ ██ ██ ██████ ███████ + * ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ █████ ██ ██ ███ ██ ██ ██████ █████ █████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██████ ██ ████ ██ ██ ██████ ██████ ██ ██ ███████ + * ███████ █████ ██ ██ ███████ + * ██ ██ ██ ██ ██ ██ + * ███████ ███████ ██ ██ █████ + * ██ ██ ██ ██ ██ ██ + * ███████ ██ ██ ████ ███████ + */ + + + add_action("configure-save", function () { + global $env, $settings, $paths, $defaultCSS; + + // If the user isn't an admin, then the regular configuration page will display an appropriate error + if(!$env->is_admin) + { + http_response_code(307); + header("location: ?action=configure"); + exit(); + } + + // Build a new settings object + $newSettings = new stdClass(); + foreach($settings as $configKey => $rawValue) + { + $configValue = $rawValue; + if(isset($_POST[$configKey])) + { + $decodedConfigValue = json_decode($_POST[$configKey]); + if(json_last_error() === JSON_ERROR_NONE) + $configValue = $decodedConfigValue; + else + $configValue = $_POST[$configKey]; + + // Convert boolean settings to a boolean, since POST + // parameters don't decode correctly. + if(is_bool($settings->$configKey)) + $configValue = in_array($configValue, [ 1, "on"], true) ? true : false; + + // If the CSS hasn't changed, then we can replace it with + // 'auto' - this will ensure that upon update the new + // default CSS will be used. Also make sure we ignore line + // ending nonsense & differences here, since they really + // don't matter + if($configKey === "css" && str_replace("\r\n", "\n", $defaultCSS) === str_replace("\r\n", "\n", $configValue)) + $configValue = "auto"; + } + + $newSettings->$configKey = $configValue; + } + + // Take a backup of the current settings file + rename($paths->settings_file, "$paths->settings_file.bak"); + // Save the new settings file + file_put_contents($paths->settings_file, json_encode($newSettings, JSON_PRETTY_PRINT)); + + $content = "

    Master settings updated sucessfully

    \n"; + $content .= "

    $settings->sitename's master settings file has been updated successfully. A backup of the original settings has been created under the name peppermint.json.bak, just in case. You can go back and continue editing the master settings file, or you can go to the " . htmlentities($settings->defaultpage) . ".

    \n"; + $content .= "

    For reference, the newly generated master settings file is as follows:

    \n"; + $content .= "\n"; + exit(page_renderer::render_main("Master Settings Updated - $settings->sitename", $content)); + }); + + 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.

    "); + } +]); + + + register_module([ "name" => "Page History", @@ -2686,1844 +2683,1844 @@ function history_add_revision(&$pageinfo, &$newsource, &$oldsource, $save_pagein } - - -register_module([ - "name" => "Recent Changes", - "version" => "0.3.3", - "author" => "Starbeamrainbowlabs", - "description" => "Adds recent changes. Access through the 'recent-changes' action.", - "id" => "feature-recent-changes", - "code" => function() { - global $settings, $env, $paths; - /** - * @api {get} ?action=recentchanges Get a list of recent changes - * @apiName RecentChanges - * @apiGroup Stats - * @apiPermission Anonymous - */ - - // Add the recent changes json file to $paths for convenience. - $paths->recentchanges = $env->storage_prefix . "recent-changes.json"; - // Create the recent changes json file if it doesn't exist - if(!file_exists($paths->recentchanges)) - file_put_contents($paths->recentchanges, "[]"); - - /* - * ██████ ███████ ██████ ███████ ███ ██ ████████ - * ██ ██ ██ ██ ██ ████ ██ ██ - * ██████ █████ ██ █████ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ███████ ██████ ███████ ██ ████ ██ - * - * ██████ ██ ██ █████ ███ ██ ██████ ███████ ███████ - * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ - * ██ ███████ ███████ ██ ██ ██ ██ ███ █████ ███████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██ ██ ██ ██ ██ ████ ██████ ███████ ███████ - */ - add_action("recent-changes", function() { - global $settings, $paths, $pageindex; - - $content = "\t\t

    Recent Changes

    \n"; - - $recent_changes = json_decode(file_get_contents($paths->recentchanges)); - - if(count($recent_changes) > 0) - { - $content .= render_recent_changes($recent_changes); - } - else - { - // No changes yet :( - $content .= "

    None yet! Try making a few changes and then check back here.

    \n"; - } - - exit(page_renderer::render("Recent Changes - $settings->sitename", $content)); - }); - - register_save_preprocessor(function(&$pageinfo, &$newsource, &$oldsource) { - global $env, $settings, $paths; - - // Work out the old and new page lengths - $oldsize = strlen($oldsource); - $newsize = strlen($newsource); - // Calculate the page length difference - $size_diff = $newsize - $oldsize; - - $newchange = [ - "type" => "edit", - "timestamp" => time(), - "page" => $env->page, - "user" => $env->user, - "newsize" => $newsize, - "sizediff" => $size_diff - ]; - if($oldsize == 0) - $newchange["newpage"] = true; - - add_recent_change($newchange); - }); - - add_help_section("800-raw-page-content", "Recent Changes", "

    The recent changes page displays a list of all the most recent changes that have happened around $settings->sitename, arranged in chronological order. It can be found in the \"More...\" menu in the top right by default.

    -

    Each entry displays the name of the page in question, who edited it, how long ago they did so, and the number of characters added or removed. Pages that currently redirect to another page are shown in italics, and hovering over the time since the edit wil show the exact time that the edit was made.

    "); - } -]); - -/** - * Adds a new recent change to the recent changes file. - * @param array $rchange The new change to add. - */ -function add_recent_change($rchange) -{ - global $settings, $paths; - - $recentchanges = json_decode(file_get_contents($paths->recentchanges), true); - array_unshift($recentchanges, $rchange); - - // Limit the number of entries in the recent changes file if we've - // been asked to. - if(isset($settings->max_recent_changes)) - $recentchanges = array_slice($recentchanges, 0, $settings->max_recent_changes); - - // Save the recent changes file back to disk - file_put_contents($paths->recentchanges, json_encode($recentchanges, JSON_PRETTY_PRINT)); -} - -function render_recent_changes($recent_changes) -{ - global $pageindex; - - // Cache the number of recent changes we are dealing with - $rchange_count = count($recent_changes); - - // Group changes made on the same page and the same day together - for($i = 0; $i < $rchange_count; $i++) - { - for($s = $i + 1; $s < $rchange_count; $s++) - { - // Break out if we have reached the end of the day we are scanning - if(date("dmY", $recent_changes[$i]->timestamp) !== date("dmY", $recent_changes[$s]->timestamp)) - break; - - // If we have found a change that has been made on the same page and - // on the same day as the one that we are scanning for, move it up - // next to the change we are scanning for. - if($recent_changes[$i]->page == $recent_changes[$s]->page && - date("j", $recent_changes[$i]->timestamp) === date("j", $recent_changes[$s]->timestamp)) - { - // FUTURE: We may need to remove and insert instead of swapping changes around if this causes some changes to appear out of order. - $temp = $recent_changes[$i + 1]; - $recent_changes[$i + 1] = $recent_changes[$s]; - $recent_changes[$s] = $temp; - $i++; - } - } - } - - $content = "
      \n"; - $last_time = 0; - for($i = 0; $i < $rchange_count; $i++) - { - $rchange = $recent_changes[$i]; - - if($last_time !== date("dmY", $rchange->timestamp)) - $content .= "
    • " . date("jS F", $rchange->timestamp) . "

    • \n"; - - $rchange_results = []; - for($s = $i; $s < $rchange_count; $s++) - { - if($recent_changes[$s]->page !== $rchange->page) - break; - - $rchange_results[$s] = render_recent_change($recent_changes[$s]); - $i++; - } - // Take one from i to account for when we tick over to the next - // iteration of the main loop - $i -= 1; - - $next_entry = implode("\n", $rchange_results); - // If the change count is greater than 1, then we should enclose it - // in a
      tag. - if(count($rchange_results) > 1) - { - reset($rchange_results); - $rchange_first = $recent_changes[key($rchange_results)]; - end($rchange_results); - $rchange_last = $recent_changes[key($rchange_results)]; - - $pageDisplayHtml = render_pagename($rchange_first); - $timeDisplayHtml = render_timestamp($rchange_first->timestamp); - $users = []; - foreach($rchange_results as $key => $rchange_result) - { - if(!in_array($recent_changes[$key]->user, $users)) - $users[] = $recent_changes[$key]->user; - } - foreach($users as &$user) - $user = page_renderer::render_username($user); - $userDisplayHtml = render_editor(implode(", ", $users)); - - $next_entry = "
    • $pageDisplayHtml $userDisplayHtml $timeDisplayHtml
        $next_entry
    • "; - - $content .= "$next_entry\n"; - } - else - { - $content .= implode("\n", $rchange_results); - } - - $last_time = date("dmY", $rchange->timestamp); - } - $content .= "\t\t
    "; - - return $content; -} - -function render_recent_change($rchange) -{ - global $pageindex; - $pageDisplayHtml = render_pagename($rchange); - $editorDisplayHtml = render_editor(page_renderer::render_username($rchange->user)); - $timeDisplayHtml = render_timestamp($rchange->timestamp); - - $revisionId = false; - if(isset($pageindex->{$rchange->page}) && isset($pageindex->{$rchange->page}->history)) - { - foreach($pageindex->{$rchange->page}->history as $historyEntry) - { - if($historyEntry->timestamp == $rchange->timestamp) - { - $revisionId = $historyEntry->rid; - break; - } - } - } - - $result = ""; - $resultClasses = []; - switch(isset($rchange->type) ? $rchange->type : "edit") - { - case "edit": - // The number (and the sign) of the size difference to display - $size_display = ($rchange->sizediff > 0 ? "+" : "") . $rchange->sizediff; - $size_display_class = $rchange->sizediff > 0 ? "larger" : ($rchange->sizediff < 0 ? "smaller" : "nochange"); - if($rchange->sizediff > 500 or $rchange->sizediff < -500) - $size_display_class .= " significant"; - - - $size_title_display = human_filesize($rchange->newsize - $rchange->sizediff) . " -> " . human_filesize($rchange->newsize); - - if(!empty($rchange->newpage)) - $resultClasses[] = "newpage"; - - $result .= "$pageDisplayHtml $editorDisplayHtml $timeDisplayHtml ($size_display)"; - break; - - case "deletion": - $resultClasses[] = "deletion"; - $result .= "$pageDisplayHtml $editorDisplayHtml $timeDisplayHtml"; - break; - - case "upload": - $resultClasses[] = "upload"; - $result .= "$pageDisplayHtml $editorDisplayHtml $timeDisplayHtml (" . human_filesize($rchange->filesize) . ")"; - break; - case "comment": - $resultClasses[] = "new-comment"; - $result .= "$pageDisplayHtml $editorDisplayHtml"; - } - - $resultAttributes = " " . (count($resultClasses) > 0 ? "class='" . implode(" ", $resultClasses) . "'" : ""); - $result = "\t\t\t$result\n"; - - return $result; -} - - - - -register_module([ - "name" => "Redirect pages", - "version" => "0.3", - "author" => "Starbeamrainbowlabs", - "description" => "Adds support for redirect pages. Uses the same syntax that Mediawiki does.", - "id" => "feature-redirect", - "code" => function() { - global $settings; - - register_save_preprocessor(function(&$index_entry, &$pagedata) { - $matches = []; - if(preg_match("/^# ?REDIRECT ?\[\[([^\]]+)\]\]/i", $pagedata, $matches) === 1) - { - //error_log("matches: " . var_export($matches, true)); - // We have found a redirect page! - // Update the metadata to reflect this. - $index_entry->redirect = true; - $index_entry->redirect_target = $matches[1]; - } - else - { - // This page isn't a redirect. Unset the metadata just in case. - if(isset($index_entry->redirect)) - unset($index_entry->redirect); - if(isset($index_entry->redirect_target)) - unset($index_entry->redirect_target); - } - }); - - // Register a help section - add_help_section("25-redirect", "Redirect Pages", "

    $settings->sitename supports redirect pages. To create a redirect page, enter something like # REDIRECT [[pagename]] on the first line of the redirect page's content. This must appear as the first line of the page, with no whitespace before it. You can include content beneath the redirect if you want, too (such as a reason for redirecting the page).

    "); - } -]); - - - - -register_module([ - "name" => "Search", - "version" => "0.5", - "author" => "Starbeamrainbowlabs", - "description" => "Adds proper search functionality to Pepperminty Wiki using an inverted index to provide a full text search engine. If pages don't show up, then you might have hit a stop word. If not, try requesting the `invindex-rebuild` action to rebuild the inverted index from scratch.", - "id" => "feature-search", - "code" => function() { - global $settings; - - /** - * @api {get} ?action=index&page={pageName} Get an index of words for a given page - * @apiName SearchIndex - * @apiGroup Search - * @apiPermission Anonymous - * - * @apiParam {string} page The page to generate a word index page. - */ - - /* - * ██ ███ ██ ██████ ███████ ██ ██ - * ██ ████ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ █████ ███ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ████ ██████ ███████ ██ ██ - */ - add_action("index", function() { - global $settings, $env; - - $breakable_chars = "\r\n\t .,\\/!\"£$%^&*[]()+`_~#"; - - header("content-type: text/plain"); - - $source = file_get_contents("$env->storage_prefix$env->page.md"); - - $index = search::index($source); - - var_dump($env->page); - var_dump($source); - - var_dump($index); - }); - - /** - * @api {get} ?action=invindex-rebuild Rebuild the inverted search index from scratch - * @apiDescription Causes the inverted search index to be completely rebuilt from scratch. Can take a while for large wikis! - * @apiName SearchInvindexRebuild - * @apiGroup Search - * @apiPermission Anonymous - */ - - /* - * ██ ███ ██ ██ ██ ██ ███ ██ ██████ ███████ ██ ██ - * ██ ████ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ███ █████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ████ ████ ██ ██ ████ ██████ ███████ ██ ██ - * - * ██████ ███████ ██████ ██ ██ ██ ██ ██████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ █████ ██████ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ███████ ██████ ██████ ██ ███████ ██████ - */ - add_action("invindex-rebuild", function() { - search::rebuild_invindex(); - }); - - - /** - * @api {get} ?action=idindex-show Show the id index - * @apiDescription Outputs the id index. Useful if you need to verify that it's working as expected. - * @apiName SearchShowIdIndex - * @apiGroup Search - * @apiPermission Anonymous - */ - add_action("idindex-show", function() { - global $idindex; - header("content-type: application/json; charset=UTF-8"); - exit(json_encode($idindex, JSON_PRETTY_PRINT)); - }); - - /** - * @api {get} ?action=search&query={text} Search the wiki for a given query string - * @apiName Search - * @apiGroup Search - * @apiPermission Anonymous - * - * @apiParam {string} query The query string to search for. - */ - - /* - * ███████ ███████ █████ ██████ ██████ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ███████ █████ ███████ ██████ ██ ███████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ███████ ███████ ██ ██ ██ ██ ██████ ██ ██ - */ - add_action("search", function() { - global $settings, $env, $pageindex, $paths; - - // Create the inverted index if it doesn't exist. - // todo In the future perhaps a CLI for this would be good? - if(!file_exists($paths->searchindex)) - search::rebuild_invindex(); - - if(!isset($_GET["query"])) - exit(page_renderer::render("No Search Terms - Error - $settings->sitename", "

    You didn't specify any search terms. Try typing some into the box above.

    ")); - - $search_start = microtime(true); - - $invindex = search::load_invindex($paths->searchindex); - $results = search::query_invindex($_GET["query"], $invindex); - $resultCount = count($results); - - $env->perfdata->search_time = round((microtime(true) - $search_start)*1000, 3); - - $title = $_GET["query"] . " - Search results - $settings->sitename"; - - $content = "
    \n"; - $content .= "

    Search Results

    "; - - /// Search Box /// - $content .= "
    \n"; - $content .= " \n"; - $content .= " \n"; - $content .= "
    "; - - $content .= "

    Found $resultCount " . ($resultCount === 1 ? "result" : "results") . " in " . $env->perfdata->search_time . "ms. "; - - $query = $_GET["query"]; - if(isset($pageindex->$query)) - { - $content .= "There's a page on $settings->sitename called $query."; - } - else - { - $content .= "There isn't a page called $query on $settings->sitename, but you "; - if((!$settings->anonedits && !$env->is_logged_in) || !$settings->editing) - { - $content .= "do not have permission to create it."; - if(!$env->is_logged_in) - { - $content .= " You could try logging in."; - } - } - else - { - $content .= "can create it."; - } - } - $content .= "

    "; - - $i = 0; // todo use $_GET["offset"] and $_GET["result-count"] or something - foreach($results as $result) - { - $link = "?page=" . rawurlencode($result["pagename"]); - $pagesource = file_get_contents($env->storage_prefix . $result["pagename"] . ".md"); - - //echo("Extracting context for result " . $result["pagename"] . ".\n"); - $context = search::extract_context($_GET["query"], $pagesource); - if(strlen($context) === 0) - $context = substr($pagesource, 0, $settings->search_characters_context * 2); - //echo("'Generated search context for " . $result["pagename"] . ": $context'\n"); - $context = search::highlight_context($_GET["query"], $context); - /*if(strlen($context) == 0) - { - $context = search::strip_markup(file_get_contents("$env->page.md", null, null, null, $settings->search_characters_context * 2)); - if($pageindex->{$env->page}->size > $settings->search_characters_context * 2) - $context .= "..."; - }*/ - - // Make redirect pages italics - if(!empty($pageindex->{$result["pagename"]}->redirect)) - $result["pagename"] = "{$result["pagename"]}"; - - // We add 1 to $i here to convert it from an index to a result - // number as people expect it to start from 1 - $content .= "
    \n"; - $content .= "

    " . $result["pagename"] . "

    \n"; - $content .= "

    $context

    \n"; - $content .= "
    \n"; - - $i++; - } - - $content .= "
    \n"; - - header("content-type: text/html; charset=UTF-8"); - exit(page_renderer::render($title, $content)); - - //header("content-type: text/plain"); - //var_dump($results); - }); - -/* - * ██████ ██ ██ ███████ ██████ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ █████ ██████ ████ █████ - * ██ ▄▄ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██████ ███████ ██ ██ ██ - * ▀▀ - * ███████ ███████ █████ ██████ ██████ ██ ██ ██ ███ ██ ██████ ███████ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ - * ███████ █████ ███████ ██████ ██ ███████ ██ ██ ██ ██ ██ ██ █████ ███ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ███████ ███████ ██ ██ ██ ██ ██████ ██ ██ ██ ██ ████ ██████ ███████ ██ ██ - */ - - /** - * @api {get} ?action=query-searchindex&query={text} Inspect the internals of the search results for a query - * @apiName Search - * @apiGroup Search - * @apiPermission Anonymous - * - * @apiParam {string} query The query string to search for. - */ - add_action("query-searchindex", function() { - global $env, $paths; - - if(empty($_GET["query"])) { - http_response_code(400); - header("content-type: text/plain"); - exit("Error: No query specified. Specify it with the 'query' GET parameter."); - } - - $env->perfdata->searchindex_decode_start = microtime(true); - $searchIndex = search::load_invindex($paths->searchindex); - $env->perfdata->searchindex_decode_time = (microtime(true) - $env->perfdata->searchindex_decode_start) * 1000; - $env->perfdata->searchindex_query_start = microtime(true); - $searchResults = search::query_invindex($_GET["query"], $searchIndex); - $env->perfdata->searchindex_query_time = (microtime(true) - $env->perfdata->searchindex_query_start) * 1000; - - header("content-type: application/json"); - $result = new stdClass(); - $result->time_format = "ms"; - $result->decode_time = $env->perfdata->searchindex_decode_time; - $result->query_time = $env->perfdata->searchindex_query_time; - $result->total_time = $result->decode_time + $result->query_time; - $result->search_results = $searchResults; - exit(json_encode($result, JSON_PRETTY_PRINT)); - }); - - /* - * ██████ ██████ ███████ ███ ██ ███████ ███████ █████ ██████ ██████ ██ ██ - * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██████ █████ ██ ██ ██ ███████ █████ ███████ ██████ ██ ███████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██ ███████ ██ ████ ███████ ███████ ██ ██ ██ ██ ██████ ██ ██ - */ - add_action("opensearch-description", function () { - global $settings; - $siteRoot = full_url() . "/index.php"; - if(!isset($_GET["debug"])) - header("content-type: application/opensearchdescription+xml"); - else - header("content-type: text/plain"); - - exit(utf8_encode(" - Search $settings->sitename - Search $settings->sitename, which is powered by Pepperminty Wiki. - $settings->sitename Wiki - $settings->favicon - Search content available under the license linked to at the bottom of the search results page. - Starbeamrainbowlabs (https://github.com/sbrl/Pepperminty-Wiki/graphs/contributors) - UTF-8 - UTF-8 - - -")); - }); - - add_action("suggest-pages", function() { - global $settings, $pageindex; - - if($settings->dynamic_page_suggestion_count === 0) - { - header("content-type: application/json"); - header("content-length: 2"); - exit("[]"); - } - - if(empty($_GET["query"])) { - http_response_code(400); - header("content-type: text/plain"); - exit("Error: You didn't specify the 'query' GET parameter."); - } - - // Rank each page name - $results = []; - foreach($pageindex as $pageName => $entry) { - $results[] = [ - "pagename" => $pageName, - // Costs: Insert: 1, Replace: 8, Delete: 6 - "distance" => levenshtein($_GET["query"], $pageName, 1, 8, 6) - ]; - } - - // Sort the page names by distance form the original query - usort($results, function($a, $b) { - if($a["distance"] == $b["distance"]) - return strcmp($a["pagename"], $b["pagename"]); - return $a["distance"] < $b["distance"] ? -1 : 1; - }); - - // Send the results to the user - header("content-type: application/json"); - exit(json_encode(array_slice($results, 0, $settings->dynamic_page_suggestion_count))); - }); - - if($settings->dynamic_page_suggestion_count > 0) - { - page_renderer::AddJSSnippet('/// Dynamic page suggestion system -// Micro snippet 8 - Promisified GET (fetched 20th Nov 2016) -function get(u){return new Promise(function(r,t,a){a=new XMLHttpRequest();a.onload=function(b,c){b=a.status;c=a.response;if(b>199&&b<300){r(c)}else{t(c)}};a.open("GET",u,true);a.send(null)})} - -window.addEventListener("load", function(event) { - var searchBox = document.querySelector("input[type=search]"); - searchBox.dataset.lastValue = ""; - searchBox.addEventListener("keyup", function(event) { - // Make sure that we don\'t keep sending requests to the server if nothing has changed - if(searchBox.dataset.lastValue == event.target.value) - return; - searchBox.dataset.lastValue = event.target.value; - // Fetch the suggestions from the server - get("?action=suggest-pages&query=" + encodeURIComponent(event.target.value)).then(function(response) { - var suggestions = JSON.parse(response), - dataList = document.getElementById("allpages"); - - // If the server sent no suggestions, then we shouldn\'t replace the contents of the datalist - if(suggestions.length == 0) - return; - - console.info(`Fetched suggestions for ${event.target.value}:`, suggestions.map(s => s.pagename)); - - // Remove all the existing suggestions - while(dataList.firstChild) { - dataList.removeChild(dataList.firstChild); - } - - // Add the new suggestions to the datalist - var optionsFrag = document.createDocumentFragment(); - suggestions.forEach(function(suggestion) { - var suggestionElement = document.createElement("option"); - suggestionElement.value = suggestion.pagename; - suggestionElement.dataset.distance = suggestion.distance; - optionsFrag.appendChild(suggestionElement); - }); - dataList.appendChild(optionsFrag); - }); - }); -}); -'); - } - } -]); - -class search -{ - // Words that we should exclude from the inverted index - public static $stop_words = [ - "a", "about", "above", "above", "across", "after", "afterwards", "again", - "against", "all", "almost", "alone", "along", "already", "also", - "although", "always", "am", "among", "amongst", "amoungst", "amount", - "an", "and", "another", "any", "anyhow", "anyone", "anything", "anyway", - "anywhere", "are", "around", "as", "at", "back", "be", "became", - "because", "become", "becomes", "becoming", "been", "before", - "beforehand", "behind", "being", "below", "beside", "besides", - "between", "beyond", "bill", "both", "bottom", "but", "by", "call", - "can", "cannot", "cant", "co", "con", "could", "couldnt", "cry", "de", - "describe", "detail", "do", "done", "down", "due", "during", "each", - "eg", "eight", "either", "eleven", "else", "elsewhere", "empty", - "enough", "etc", "even", "ever", "every", "everyone", "everything", - "everywhere", "except", "few", "fill", "find", - "fire", "first", "five", "for", "former", "formerly", "found", - "four", "from", "front", "full", "further", "get", "give", "go", "had", - "has", "hasnt", "have", "he", "hence", "her", "here", "hereafter", - "hereby", "herein", "hereupon", "hers", "herself", "him", "himself", - "his", "how", "however", "ie", "if", "in", "inc", "indeed", - "interest", "into", "is", "it", "its", "itself", "keep", "last", - "latter", "latterly", "least", "less", "ltd", "made", "many", "may", - "me", "meanwhile", "might", "mine", "more", "moreover", "most", - "mostly", "move", "much", "must", "my", "myself", "name", "namely", - "neither", "never", "nevertheless", "next", "nine", "no", "none", - "nor", "not", "nothing", "now", "nowhere", "of", "off", "often", "on", - "once", "one", "only", "onto", "or", "other", "others", "otherwise", - "our", "ours", "ourselves", "out", "over", "own", "part", "per", - "perhaps", "please", "put", "rather", "re", "same", "see", "seem", - "seemed", "seeming", "seems", "serious", "several", "she", "should", - "show", "side", "since", "sincere", "six", "sixty", "so", "some", - "somehow", "someone", "something", "sometime", "sometimes", - "somewhere", "still", "such", "system", "take", "ten", "than", "that", - "the", "their", "them", "themselves", "then", "thence", "there", - "thereafter", "thereby", "therefore", "therein", "thereupon", "these", - "they", "thickv", "thin", "third", "this", "those", "though", "three", - "through", "throughout", "thru", "thus", "to", "together", "too", "top", - "toward", "towards", "twelve", "twenty", "two", "un", "under", "until", - "up", "upon", "us", "very", "via", "was", "we", "well", "were", "what", - "whatever", "when", "whence", "whenever", "where", "whereafter", - "whereas", "whereby", "wherein", "whereupon", "wherever", "whether", - "which", "while", "whither", "who", "whoever", "whole", "whom", "whose", - "why", "will", "with", "within", "without", "would", "yet", "you", - "your", "yours", "yourself", "yourselves" - ]; - - public static function index($source) - { - $source = html_entity_decode($source, ENT_QUOTES); - $source_length = strlen($source); - - $index = []; - - $terms = self::tokenize($source); - $i = 0; - foreach($terms as $term) - { - $nterm = $term; - - // Skip over stop words (see https://en.wikipedia.org/wiki/Stop_words) - if(in_array($nterm, self::$stop_words)) continue; - - if(!isset($index[$nterm])) - { - $index[$nterm] = [ "freq" => 0, "offsets" => [] ]; - } - - $index[$nterm]["freq"]++; - $index[$nterm]["offsets"][] = $i; - - $i++; - } - - return $index; - } - - public static function tokenize($source) - { - $source = strtolower($source); - $source = str_replace([ '[', ']', '|', '{', '}' ], " ", $source); - return preg_split("/((^\p{P}+)|(\p{P}*\s+\p{P}*)|(\p{P}+$))|\|/u", $source, -1, PREG_SPLIT_NO_EMPTY); - } - - public static function strip_markup($source) - { - return str_replace([ "[", "]", "\"", "*", "_", " - ", "`" ], "", $source); - } - - public static function rebuild_invindex() - { - global $pageindex, $env, $paths; - - header("content-type: text/event-stream"); - - // Clear the id index out - ids::clear(); - - // Reindex each page in turn - $invindex = []; - foreach($pageindex as $pagename => $pagedetails) - { - $pagesource = utf8_encode(file_get_contents("$env->storage_prefix$pagename.md")); - $index = self::index($pagesource); - - $pageid = ids::getid($pagename); - self::merge_into_invindex($invindex, $pageid, $index); - - echo("Added $pagename (id #$pageid) to the new search index.\n\n"); - flush(); - } - - echo("Search index rebuilding complete.\n\n"); - echo("Saving new search index to '$paths->searchindex'.\n\n"); - - self::save_invindex($paths->searchindex, $invindex); - } - - /* - * @summary Sorts an index alphabetically. Will also sort an inverted index. - * This allows us to do a binary search instead of a regular - * sequential search. - */ - public static function sort_index(&$index) - { - ksort($index, SORT_NATURAL); - } - - /* - * @summary Compares two *regular* indexes to find the differences between them. - * - * @param {array} $indexa - The old index. - * @param {array} $indexb - The new index. - * @param {array} $changed - An array to be filled with the nterms of all - * the changed entries. - * @param {array} $removed - An array to be filled with the nterms of all - * the removed entries. - */ - public static function compare_indexes($oldindex, $newindex, &$changed, &$removed) - { - foreach($oldindex as $nterm => $entry) - { - if(!isset($newindex[$nterm])) - $removed[] = $nterm; - } - foreach($newindex as $nterm => $entry) - { - if(!isset($oldindex[$nterm]) or // If this world is new - $newindex[$nterm] !== $oldindex[$nterm]) // If this word has changed - $changed[$nterm] = $newindex[$nterm]; - } - } - - /* - * @summary Reads in and parses an inverted index. - */ - // Todo remove this function and make everything streamable - public static function load_invindex($invindex_filename) { - $invindex = json_decode(file_get_contents($invindex_filename), true); - return $invindex; - } - - public static function measure_invindex_load_time($invindex_filename) { - global $env; - - $searchindex_decode_start = microtime(true); - search::load_invindex($invindex_filename); - $env->perfdata->searchindex_decode_time = round((microtime(true) - $searchindex_decode_start)*1000, 3); - } - - /* - * @summary Merge an index into an inverted index. - */ - public static function merge_into_invindex(&$invindex, $pageid, &$index, &$removals = []) - { - // Remove all the subentries that were removed since last time - foreach($removals as $nterm) - { - unset($invindex[$nterm][$pageid]); - } - - // Merge all the new / changed index entries into the inverted index - foreach($index as $nterm => $newentry) - { - // If the nterm isn't in the inverted index, then create a space for it - if(!isset($invindex[$nterm])) $invindex[$nterm] = []; - $invindex[$nterm][$pageid] = $newentry; - - // Sort the page entries for this word by frequency - uasort($invindex[$nterm], function($a, $b) { - if($a["freq"] == $b["freq"]) return 0; - return ($a["freq"] < $b["freq"]) ? +1 : -1; - }); - } - - // Sort the inverted index by rank - uasort($invindex, function($a, $b) { - $ac = count($a); $bc = count($b); - if($ac == $bc) return 0; - return ($ac < $bc) ? +1 : -1; - }); - } - - /** - * Deletes the given pageid from the given pageindex. - * @param inverted_index &$invindex The inverted index. - * @param number $pageid The pageid to remove. - */ - public static function delete_entry(&$invindex, $pageid) - { - $str_pageid = (string)$pageid; - foreach($invindex as $nterm => &$entry) - { - if(isset($entry[$pageid])) - unset($entry[$pageid]); - if(isset($entry[$str_pageid])) - unset($entry[$str_pageid]); - if(count($entry) === 0) - unset($invindex[$nterm]); - } - } - - public static function save_invindex($filename, &$invindex) - { - file_put_contents($filename, json_encode($invindex)); - } - - public static function query_invindex($query, &$invindex) - { - global $settings, $pageindex; - - $query_terms = self::tokenize($query); - $matching_pages = []; - - - // Loop over each term in the query and find the matching page entries - $count = count($query_terms); - for($i = 0; $i < $count; $i++) - { - $qterm = $query_terms[$i]; - - // Stop words aren't worth the bother - make sure we don't search - // the title or the tags for them - if(in_array($qterm, self::$stop_words)) - continue; - - // Only search the inverted index if it actually exists there - if(isset($invindex[$qterm])) - { - // Loop over each page in the inverted index entry - foreach($invindex[$qterm] as $pageid => $page_entry) - { - // Create an entry in the matching pages array if it doesn't exist - if(!isset($matching_pages[$pageid])) - $matching_pages[$pageid] = [ "nterms" => [] ]; - $matching_pages[$pageid]["nterms"][$qterm] = $page_entry; - } - } - - - // Loop over the pageindex and search the titles / tags - foreach ($pageindex as $pagename => $pagedata) - { - // Get the current page's id - $pageid = ids::getid($pagename); - // Consider matches in the page title - if(stripos($pagename, $qterm) !== false) - { - // We found the qterm in the title - if(!isset($matching_pages[$pageid])) - $matching_pages[$pageid] = [ "nterms" => [] ]; - - // Set up a counter for page title matches if it doesn't exist already - if(!isset($matching_pages[$pageid]["title-matches"])) - $matching_pages[$pageid]["title-matches"] = 0; - - $matching_pages[$pageid]["title-matches"] += count(mb_stripos_all($pagename, $qterm)); - } - - // Consider matches in the page's tags - if(isset($pagedata->tags) and // If this page has tags - stripos(implode(" ", $pagedata->tags), $qterm) !== false) // And we found the qterm in the tags - { - if(!isset($matching_pages[$pageid])) - $matching_pages[$pageid] = [ "nterms" => [] ]; - - // Set up a counter for tag match if there isn't one already - if(!isset($matching_pages[$pageid]["tag-matches"])) - $matching_pages[$pageid]["tag-matches"] = 0; - $matching_pages[$pageid]["tag-matches"] += count(mb_stripos_all(implode(" ", $pagedata->tags), $qterm)); - } - } - } - - - foreach($matching_pages as $pageid => &$pagedata) - { - $pagedata["pagename"] = ids::getpagename($pageid); - $pagedata["rank"] = 0; - - $pageOffsets = []; - - // Loop over each search term found on this page - foreach($pagedata["nterms"] as $pterm => $entry) - { - // Add the number of occurrences of this search term to the ranking - $pagedata["rank"] += $entry["freq"]; - - // Add the offsets to a listof all offsets on this page - foreach($entry["offsets"] as $offset) - $pageOffsets[] = $offset; - } - /* - // Sort the list of offsets - $pageOffsets = array_unique($pageOffsets); - sort($pageOffsets); - var_dump($pageOffsets); - - // Calcualate the clump distances via a variable moving window size - $pageOffsetsCount = count($pageOffsets); - $clumpDistanceWindow = min($count, $pageOffsetsCount); // a.k.a. count($query_terms) - see above - $clumpDistances = []; - for($i = 0; $i < $pageOffsetsCount - $clumpDistanceWindow; $i++) - $clumpDistances[] = $pageOffsets[$i] - $pageOffsets[$i + $clumpDistanceWindow]; - - // Sort the new list of clump distances - sort($clumpDistances); - // Calcualate a measureof how clumped the offsets are - $tightClumpLimit = floor((count($clumpDistances) - 1) / 0.25); - $tightClumpsMeasure = $clumpDistances[$tightClumpLimit] - $clumpDistances[0]; - $clumpsRange = $clumpDistances[count($clumpDistances) - 1] - $clumpDistances[0]; - - $clumpiness = $tightClumpsMeasure / $clumpsRange; - echo("{$pagedata["pagename"]} - $clumpiness"); - */ - - // Consider matches in the title / tags - if(isset($pagedata["title-matches"])) - $pagedata["rank"] += $pagedata["title-matches"] * $settings->search_title_matches_weighting; - if(isset($pagedata["tag-matches"])) - $pagedata["rank"] += $pagedata["tag-matches"] * $settings->search_tags_matches_weighting; - - // todo remove items if the rank is below a threshold - } - - // todo sort by rank here - uasort($matching_pages, function($a, $b) { - if($a["rank"] == $b["rank"]) return 0; - return ($a["rank"] < $b["rank"]) ? +1 : -1; - }); - - return $matching_pages; - } - - public static function extract_context($query, $source) - { - global $settings; - - $nterms = self::tokenize($query); - $matches = []; - // Loop over each nterm and find it in the source - foreach($nterms as $nterm) - { - if(in_array($nterm, static::$stop_words)) - continue; - $all_offsets = mb_stripos_all($source, $nterm); - // Skip over adding matches if there aren't any - if($all_offsets === false) - continue; - foreach($all_offsets as $offset) - { - $matches[] = [ $nterm, $offset ]; - } - } - - // Sort the matches by offset - usort($matches, function($a, $b) { - if($a[1] == $b[1]) return 0; - return ($a[1] > $b[1]) ? +1 : -1; - }); - - $sourceLength = strlen($source); - - $contexts = []; - $basepos = 0; - $matches_count = count($matches); - while($basepos < $matches_count) - { - // Store the next match along - all others will be relative to that one - $group = [$matches[$basepos]]; - - // Start scanning at the next one along - we always store the first match - $scanpos = $basepos + 1; - $distance = 0; - - while(true) - { - // Break out if we reach the end - if($scanpos >= $matches_count) break; - - // Find the distance between the current one and the last one - $distance = $matches[$scanpos][1] - $matches[$scanpos - 1][1]; - - // Store it if the distance is below the threshold - if($distance < $settings->search_characters_context) - $group[] = $matches[$scanpos]; - else - break; - - $scanpos++; - } - - $context_start = $group[0][1] - $settings->search_characters_context; - $context_end = $group[count($group) - 1][1] + $settings->search_characters_context; - - if($context_start < 0) $context_start = 0; - if($context_end > $sourceLength) $context_end = $sourceLength; - - //echo("Got context. Start: $context_start, End: $context_end\n"); - //echo("Group:"); var_dump($group); - - $context = substr($source, $context_start, $context_end - $context_start); - - // Strip the markdown from the context - it's most likely going to - // be broken anyway. - //$context = self::strip_markup($context); - - // Escape special characters to protect against attacks - $context = htmlentities($context); - - $contexts[] = $context; - - $basepos = $scanpos + 1; - } - - return implode(" ... ", $contexts); - } - - public static function highlight_context($query, $context) - { - $qterms = self::tokenize($query); - - foreach($qterms as $qterm) - { - if(in_array($qterm, static::$stop_words)) - continue; - // From http://stackoverflow.com/a/2483859/1460422 - $context = preg_replace("/" . str_replace("/", "\/", preg_quote($qterm)) . "/i", "$0", $context); - } - - return $context; - } -} - - - - -register_module([ - "name" => "Uploader", - "version" => "0.5.8", - "author" => "Starbeamrainbowlabs", - "description" => "Adds the ability to upload files to Pepperminty Wiki. Uploaded files act as pages and have the special 'File/' prefix.", - "id" => "feature-upload", - "code" => function() { - global $settings; - /** - * @api {get} ?action=upload Get a page to let you upload a file. - * @apiName UploadFilePage - * @apiGroup Upload - * @apiPermission User - */ - - /** - * @api {post} ?action=upload Upload a file - * @apiName UploadFile - * @apiGroup Upload - * @apiPermission User - * - * @apiParam {string} name The name of the file to upload. - * @apiParam {string} description A description of the file. - * @apiParam {file} file The file to upload. - * - * @apiUse UserNotLoggedInError - * @apiError UploadsDisabledError Uploads are currently disabled in the wiki's settings. - * @apiError UnknownFileTypeError The type of the file you uploaded is not currently allowed in the wiki's settings. - * @apiError ImageDimensionsFiledError PeppermintyWiki couldn't obtain the dimensions of the image you uploaded. - * @apiError DangerousFileError The file uploaded appears to be dangerous. - * @apiError DuplicateFileError The filename specified is a duplicate of a file that already exists. - * @apiError FileTamperedError Pepperminty Wiki couldn't verify that the file wasn't tampered with during theupload process. - */ - - /* - * ██ ██ ██████ ██ ██████ █████ ██████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██████ ██ ██ ██ ███████ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ██ ███████ ██████ ██ ██ ██████ - */ - add_action("upload", function() { - global $settings, $env, $pageindex, $paths; - - switch($_SERVER["REQUEST_METHOD"]) - { - case "GET": - // Send upload page - - if(!$settings->upload_enabled) - exit(page_renderer::render("Upload Disabled - $setting->sitename", "

    You can't upload anything at the moment because $settings->sitename has uploads disabled. Try contacting $settings->admindetails_name, your site Administrator. Go back.

    ")); - if(!$env->is_logged_in) - exit(page_renderer::render("Upload Error - $settings->sitename", "

    You are not currently logged in, so you can't upload anything.

    -

    Try logging in first.

    ")); - - exit(page_renderer::render("Upload - $settings->sitename", "

    Upload file

    -

    Select an image below, and then type a name for it in the box. This server currently supports uploads up to " . human_filesize(get_max_upload_size()) . " in size.

    -

    $settings->sitename currently supports uploading of the following file types: " . implode(", ", $settings->upload_allowed_file_types) . ".

    -
    - - -
    - - -
    - - -

    $settings->editing_message

    - -
    - ")); - - break; - - case "POST": - // Recieve file - - // Make sure uploads are enabled - if(!$settings->upload_enabled) - { - 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) - { - 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.")); - } - - // Calculate the target name, removing any characters we - // are unsure about. - $target_name = makepathsafe($_POST["name"]); - $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 - switch(substr($mime_type, 0, strpos($mime_type, "/"))) - { - case "image": - $extra_data = []; - // Check SVG uploads with a special function - $imagesize = $mime_type !== "image/svg+xml" ? getimagesize($temp_filename, $extra_data) : upload_check_svg($temp_filename); - - // Make sure that the image size is defined - if(!is_int($imagesize[0]) or !is_int($imagesize[1])) - { - http_response_code(415); - exit(page_renderer::render("Upload Error - $settings->sitename", "

    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" ])) - { - 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.

    ")); - } - - $new_filename = "$paths->upload_file_prefix$target_name.$file_extension"; - $new_description_filename = "$new_filename.md"; - - if(isset($pageindex->$new_filename)) - exit(page_renderer::render("Upload Error - $settings->sitename", "

    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.

    ")); - - if(!file_exists($env->storage_prefix . "Files")) - mkdir($env->storage_prefix . "Files", 0775); - - 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 $settings->sitename has been attacked. Please contact " . $settings->admindetails_name . ", your $settings->sitename Administrator.

    ")); - } - - $description = $_POST["description"]; - - // 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); - $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_filename = $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_filename, - "user" => $env->user, - "filesize" => filesize($env->storage_prefix . $entry->uploadedfilepath) - ]); - } - - header("location: ?action=view&page=$new_filename&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 = $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\""); - - // If the size is set or 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"; - - $preview_sizes = [ 256, 512, 768, 1024, 1440 ]; - $preview_html .= "\t\t\t
    - - \n\t\t\t
    "; - break; - - case "video": - $preview_html .= "\t\t\t
    - -
    "; - break; - - case "audio": - $preview_html .= "\t\t\t
    - -
    "; - } - - $fileInfo = []; - $fileInfo["Name"] = str_replace("File/", "", $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; - - $preview_html .= "\t\t\t

    File Information

    - "; - foreach ($fileInfo as $displayName => $displayValue) - { - $preview_html .= "\n"; - } - $preview_html .= "
    $displayName$displayValue
    "; - - $parts["{content}"] = str_replace("", "\n$preview_html", $parts["{content}"]); - } - }); - - // Register a section on the help page on uploading files - add_help_section("28-uploading-files", "Uploading Files", "

    $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.

    "); - } -]); - -//// Pair of functions to calculate the actual maximum upload size supported by the server -//// Lifted from Drupal by @meustrus from Stackoverflow. Link to answer: -//// http://stackoverflow.com/a/25370978/1460422 -// Returns a file size limit in bytes based on the PHP upload_max_filesize -// and post_max_size -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; -} - -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); - } -} - -function upload_check_svg($temp_filename) -{ - global $settings; - // Check for script tags - if(strpos(file_get_contents($temp_filename), "sitename", "

    $settings->sitename detected that you uploaded an SVG image and performed some extra security checks on your file. Whilst performing these checks it was discovered that the file you uploaded contains some Javascript, which could be dangerous. 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.

    ")); - } - - // Find and return the size of the SVG image - return getsvgsize($temp_filename); -} - -function getsvgsize($svgFilename) -{ - $svg = simplexml_load_file($svgFilename); // Load it as XML - if($svg === false) - { - http_response_code(415); - exit(page_renderer::render("Upload Error - $settings->sitename", "

    When $settings->sitename tried to open your SVG file for checking, it found some invalid syntax. The uploaded file has been discarded. Go back to try again.

    ")); - } - $rootAttrs = $svg->attributes(); - $imageSize = false; - if(isset($rootAttrs->width) and isset($rootAttrs->height)) - $imageSize = [ intval($rootAttrs->width), intval($rootAttrs->height) ]; - else if(isset($rootAttrs->viewBox)) - $imageSize = array_map("intval", array_slice(explode(" ", $rootAttrs->viewBox), -2, 2)); - - return $imageSize; -} - -function errorimage($text, $target_size = null) -{ - $width = 640; - $height = 480; - - if(!empty($target_size)) - { - $width = $target_size; - $height = $target_size * (2 / 3); - } - - $image = imagecreatetruecolor($width, $height); - imagefill($image, 0, 0, imagecolorallocate($image, 238, 232, 242)); // Set the background to #eee8f2 - $fontwidth = imagefontwidth(3); - imagestring($image, 3, - ($width / 2) - (($fontwidth * strlen($text)) / 2), - ($height / 2) - (imagefontheight(3) / 2), - $text, - imagecolorallocate($image, 17, 17, 17) // #111111 - ); - - return $image; -} - - - - -register_module([ - "name" => "User Preferences", - "version" => "0.2.1", - "author" => "Starbeamrainbowlabs", - "description" => "Adds a user preferences page, letting pople do things like change their email address and password.", - "id" => "feature-user-preferences", - "code" => function() { - global $env, $settings; - /** - * @api {get} ?action=user-preferences Get a user preferences configuration page. - * @apiName UserPreferences - * @apiGroup Settings - * @apiPermission User - */ - - /* - * ██ ██ ███████ ███████ ██████ - * ██ ██ ██ ██ ██ ██ - * ██ ██ ███████ █████ ██████ █████ - * ██ ██ ██ ██ ██ ██ - * ██████ ███████ ███████ ██ ██ - * - * ██████ ██████ ███████ ███████ ███████ - * ██ ██ ██ ██ ██ ██ ██ - * ██████ ██████ █████ █████ ███████ - * ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ███████ ██ ███████ - */ - add_action("user-preferences", function() { - global $env, $settings; - - if(!$env->is_logged_in) - { - exit(page_renderer::render_main("Error - $settings->sitename", "

    Since you aren't logged in, you can't change your preferences. This is because stored preferences are tied to each registered user account. You can login here.

    ")); - } - - $statusMessages = [ - "change-password" => "Password changed successfully!" - ]; - - if(!isset($env->user_data->emailAddress)) { - $env->user_data->emailAddress = ""; - save_userdata(); - } - - $content = "

    User Preferences

    \n"; - if(isset($_GET["success"]) && $_GET["success"] === "yes") - { - $content .= "

    " . $statusMessages[$_GET["operation"]] . "

    \n"; - } - $content .= "\n"; - $content .= "\n"; - $content .= "
    \n"; - $content .= " \n"; - $content .= " \n"; - $content .= "

    Used to send you notifications etc. Never shared with anyone except $settings->admindetails_name, $settings->sitename's administrator.

    \n"; - $content .= " \n"; - $content .= "
    \n"; - $content .= "

    Change Password"; - $content .= "
    \n"; - $content .= " \n"; - $content .= " \n"; - $content .= "
    \n"; - $content .= " \n"; - $content .= " \n"; - $content .= "
    \n"; - $content .= " \n"; - $content .= " \n"; - $content .= "
    \n"; - $content .= " \n"; - $content .= "
    \n"; - - if($env->is_admin) - $content .= "

    As an admin, you can also edit $settings->sitename's master settings.

    \n"; - - exit(page_renderer::render_main("User Preferences - $settings->sitename", $content)); - }); - - add_action("save-preferences", function() { - global $env, $settings; - - if(!$env->is_logged_in) - { - http_response_code(400); - exit(page_renderer::render_main("Error Saving Preferences - $settings->sitename", "

    You aren't logged in, so you can't save your preferences. Try logging in first.

    ")); - } - - if(isset($_POST["email-address"])) - { - if(mb_strlen($_POST["email-address"]) > 320) - { - http_response_code(413); - exit(page_renderer::render_main("Error Saving Email Address - $settings->sitename", "

    The email address you supplied ({$_POST['email-address']}) is too long. Email addresses can only be 320 characters long. Go back.")); - } - - if(mb_strpos($_POST["email-address"], "@") === false) - { - http_response_code(422); - exit(page_renderer::render_main("Error Saving Email Address - $settings->sitename", "

    The email address you supplied ({$_POST['email-address']}) doesn't appear to be valid. Go back.")); - } - - $env->user_data->emailAddress = $_POST["email-address"]; - } - - // Save the user's preferences - if(!save_userdata()) - { - http_response_code(503); - exit(page_renderer::render_main("Error Saving Preferences - $settings->sitename", "

    $settings->sitename had some trouble saving your preferences! Please contact $settings->admindetails_name, $settings->sitename's administrator and tell them about this error if it still occurs in 5 minutes. They can be contacted by email at this address: " . hide_email($settings->admindetails_email) . ".

    ")); - } - - exit(page_renderer::render_main("Preferences Saved Successfully - $settings->sitename", "

    Your preferences have been saved successfully! You could go back your preferences page, or on to the $settings->defaultpage.

    ")); - }); - - /** - * @api {post} ?action=change-password Change your password - * @apiName ChangePassword - * @apiGroup Settings - * @apiPermission User - * - * @apiParam {string} current-pass Your current password. - * @apiParam {string} new-pass Your new password. - * @apiParam {string} new-pass-confirm Your new password again, to make sure you've typed it correctly. - * - * @apiError PasswordMismatchError The new password fields don't match. - */ - add_action("change-password", function() { - global $env, $settings; - - // Make sure the new password was typed correctly - // This comes before the current password check since that's more intensive - if($_POST["new-pass"] !== $_POST["new-pass-confirm"]) { - exit(page_renderer::render_main("Password mismatch - $settings->sitename", "

    The new password you typed twice didn't match! Go back.

    ")); - } - // Check the current password - if(hash_password($_POST["current-pass"]) !== $env->user_data->password) { - exit(page_renderer::render_main("Password mismatch - $settings->sitename", "

    Error: You typed your current password incorrectly! Go back.

    ")); - } - - // All's good! Go ahead and change the password. - $env->user_data->password = hash_password($_POST["new-pass"]); - // Save the userdata back to disk - save_userdata(); - - http_response_code(307); - header("location: ?action=user-preferences&success=yes&operation=change-password"); - exit(page_renderer::render_main("Password Changed Successfully", "

    You password was changed successfully. Go back to the user preferences page.

    ")); - }); - - // Display a help section on the user preferences, but only if the user - // is logged in and so able to access them - if($env->is_logged_in) - { - add_help_section("910-user-preferences", "User Preferences", "

    As you are logged in, $settings->sitename lets you configure a selection of personal preferences. These can be viewed and tweaked to you liking over on the preferences page, which can be accessed at any time by clicking the cog icon (it looks something like this: $settings->user_preferences_button_text), though the administrator of $settings->sitename ($settings->admindetails_name) may have changed its appearance.

    "); - } - } -]); - - - + + +register_module([ + "name" => "Recent Changes", + "version" => "0.3.3", + "author" => "Starbeamrainbowlabs", + "description" => "Adds recent changes. Access through the 'recent-changes' action.", + "id" => "feature-recent-changes", + "code" => function() { + global $settings, $env, $paths; + /** + * @api {get} ?action=recentchanges Get a list of recent changes + * @apiName RecentChanges + * @apiGroup Stats + * @apiPermission Anonymous + */ + + // Add the recent changes json file to $paths for convenience. + $paths->recentchanges = $env->storage_prefix . "recent-changes.json"; + // Create the recent changes json file if it doesn't exist + if(!file_exists($paths->recentchanges)) + file_put_contents($paths->recentchanges, "[]"); + + /* + * ██████ ███████ ██████ ███████ ███ ██ ████████ + * ██ ██ ██ ██ ██ ████ ██ ██ + * ██████ █████ ██ █████ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ███████ ██████ ███████ ██ ████ ██ + * + * ██████ ██ ██ █████ ███ ██ ██████ ███████ ███████ + * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ + * ██ ███████ ███████ ██ ██ ██ ██ ███ █████ ███████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██ ██ ██ ██ ██ ████ ██████ ███████ ███████ + */ + add_action("recent-changes", function() { + global $settings, $paths, $pageindex; + + $content = "\t\t

    Recent Changes

    \n"; + + $recent_changes = json_decode(file_get_contents($paths->recentchanges)); + + if(count($recent_changes) > 0) + { + $content .= render_recent_changes($recent_changes); + } + else + { + // No changes yet :( + $content .= "

    None yet! Try making a few changes and then check back here.

    \n"; + } + + exit(page_renderer::render("Recent Changes - $settings->sitename", $content)); + }); + + register_save_preprocessor(function(&$pageinfo, &$newsource, &$oldsource) { + global $env, $settings, $paths; + + // Work out the old and new page lengths + $oldsize = strlen($oldsource); + $newsize = strlen($newsource); + // Calculate the page length difference + $size_diff = $newsize - $oldsize; + + $newchange = [ + "type" => "edit", + "timestamp" => time(), + "page" => $env->page, + "user" => $env->user, + "newsize" => $newsize, + "sizediff" => $size_diff + ]; + if($oldsize == 0) + $newchange["newpage"] = true; + + add_recent_change($newchange); + }); + + add_help_section("800-raw-page-content", "Recent Changes", "

    The recent changes page displays a list of all the most recent changes that have happened around $settings->sitename, arranged in chronological order. It can be found in the \"More...\" menu in the top right by default.

    +

    Each entry displays the name of the page in question, who edited it, how long ago they did so, and the number of characters added or removed. Pages that currently redirect to another page are shown in italics, and hovering over the time since the edit wil show the exact time that the edit was made.

    "); + } +]); + +/** + * Adds a new recent change to the recent changes file. + * @param array $rchange The new change to add. + */ +function add_recent_change($rchange) +{ + global $settings, $paths; + + $recentchanges = json_decode(file_get_contents($paths->recentchanges), true); + array_unshift($recentchanges, $rchange); + + // Limit the number of entries in the recent changes file if we've + // been asked to. + if(isset($settings->max_recent_changes)) + $recentchanges = array_slice($recentchanges, 0, $settings->max_recent_changes); + + // Save the recent changes file back to disk + file_put_contents($paths->recentchanges, json_encode($recentchanges, JSON_PRETTY_PRINT)); +} + +function render_recent_changes($recent_changes) +{ + global $pageindex; + + // Cache the number of recent changes we are dealing with + $rchange_count = count($recent_changes); + + // Group changes made on the same page and the same day together + for($i = 0; $i < $rchange_count; $i++) + { + for($s = $i + 1; $s < $rchange_count; $s++) + { + // Break out if we have reached the end of the day we are scanning + if(date("dmY", $recent_changes[$i]->timestamp) !== date("dmY", $recent_changes[$s]->timestamp)) + break; + + // If we have found a change that has been made on the same page and + // on the same day as the one that we are scanning for, move it up + // next to the change we are scanning for. + if($recent_changes[$i]->page == $recent_changes[$s]->page && + date("j", $recent_changes[$i]->timestamp) === date("j", $recent_changes[$s]->timestamp)) + { + // FUTURE: We may need to remove and insert instead of swapping changes around if this causes some changes to appear out of order. + $temp = $recent_changes[$i + 1]; + $recent_changes[$i + 1] = $recent_changes[$s]; + $recent_changes[$s] = $temp; + $i++; + } + } + } + + $content = "
      \n"; + $last_time = 0; + for($i = 0; $i < $rchange_count; $i++) + { + $rchange = $recent_changes[$i]; + + if($last_time !== date("dmY", $rchange->timestamp)) + $content .= "
    • " . date("jS F", $rchange->timestamp) . "

    • \n"; + + $rchange_results = []; + for($s = $i; $s < $rchange_count; $s++) + { + if($recent_changes[$s]->page !== $rchange->page) + break; + + $rchange_results[$s] = render_recent_change($recent_changes[$s]); + $i++; + } + // Take one from i to account for when we tick over to the next + // iteration of the main loop + $i -= 1; + + $next_entry = implode("\n", $rchange_results); + // If the change count is greater than 1, then we should enclose it + // in a
      tag. + if(count($rchange_results) > 1) + { + reset($rchange_results); + $rchange_first = $recent_changes[key($rchange_results)]; + end($rchange_results); + $rchange_last = $recent_changes[key($rchange_results)]; + + $pageDisplayHtml = render_pagename($rchange_first); + $timeDisplayHtml = render_timestamp($rchange_first->timestamp); + $users = []; + foreach($rchange_results as $key => $rchange_result) + { + if(!in_array($recent_changes[$key]->user, $users)) + $users[] = $recent_changes[$key]->user; + } + foreach($users as &$user) + $user = page_renderer::render_username($user); + $userDisplayHtml = render_editor(implode(", ", $users)); + + $next_entry = "
    • $pageDisplayHtml $userDisplayHtml $timeDisplayHtml
        $next_entry
    • "; + + $content .= "$next_entry\n"; + } + else + { + $content .= implode("\n", $rchange_results); + } + + $last_time = date("dmY", $rchange->timestamp); + } + $content .= "\t\t
    "; + + return $content; +} + +function render_recent_change($rchange) +{ + global $pageindex; + $pageDisplayHtml = render_pagename($rchange); + $editorDisplayHtml = render_editor(page_renderer::render_username($rchange->user)); + $timeDisplayHtml = render_timestamp($rchange->timestamp); + + $revisionId = false; + if(isset($pageindex->{$rchange->page}) && isset($pageindex->{$rchange->page}->history)) + { + foreach($pageindex->{$rchange->page}->history as $historyEntry) + { + if($historyEntry->timestamp == $rchange->timestamp) + { + $revisionId = $historyEntry->rid; + break; + } + } + } + + $result = ""; + $resultClasses = []; + switch(isset($rchange->type) ? $rchange->type : "edit") + { + case "edit": + // The number (and the sign) of the size difference to display + $size_display = ($rchange->sizediff > 0 ? "+" : "") . $rchange->sizediff; + $size_display_class = $rchange->sizediff > 0 ? "larger" : ($rchange->sizediff < 0 ? "smaller" : "nochange"); + if($rchange->sizediff > 500 or $rchange->sizediff < -500) + $size_display_class .= " significant"; + + + $size_title_display = human_filesize($rchange->newsize - $rchange->sizediff) . " -> " . human_filesize($rchange->newsize); + + if(!empty($rchange->newpage)) + $resultClasses[] = "newpage"; + + $result .= "$pageDisplayHtml $editorDisplayHtml $timeDisplayHtml ($size_display)"; + break; + + case "deletion": + $resultClasses[] = "deletion"; + $result .= "$pageDisplayHtml $editorDisplayHtml $timeDisplayHtml"; + break; + + case "upload": + $resultClasses[] = "upload"; + $result .= "$pageDisplayHtml $editorDisplayHtml $timeDisplayHtml (" . human_filesize($rchange->filesize) . ")"; + break; + case "comment": + $resultClasses[] = "new-comment"; + $result .= "$pageDisplayHtml $editorDisplayHtml"; + } + + $resultAttributes = " " . (count($resultClasses) > 0 ? "class='" . implode(" ", $resultClasses) . "'" : ""); + $result = "\t\t\t$result\n"; + + return $result; +} + + + + +register_module([ + "name" => "Redirect pages", + "version" => "0.3", + "author" => "Starbeamrainbowlabs", + "description" => "Adds support for redirect pages. Uses the same syntax that Mediawiki does.", + "id" => "feature-redirect", + "code" => function() { + global $settings; + + register_save_preprocessor(function(&$index_entry, &$pagedata) { + $matches = []; + if(preg_match("/^# ?REDIRECT ?\[\[([^\]]+)\]\]/i", $pagedata, $matches) === 1) + { + //error_log("matches: " . var_export($matches, true)); + // We have found a redirect page! + // Update the metadata to reflect this. + $index_entry->redirect = true; + $index_entry->redirect_target = $matches[1]; + } + else + { + // This page isn't a redirect. Unset the metadata just in case. + if(isset($index_entry->redirect)) + unset($index_entry->redirect); + if(isset($index_entry->redirect_target)) + unset($index_entry->redirect_target); + } + }); + + // Register a help section + add_help_section("25-redirect", "Redirect Pages", "

    $settings->sitename supports redirect pages. To create a redirect page, enter something like # REDIRECT [[pagename]] on the first line of the redirect page's content. This must appear as the first line of the page, with no whitespace before it. You can include content beneath the redirect if you want, too (such as a reason for redirecting the page).

    "); + } +]); + + + + +register_module([ + "name" => "Search", + "version" => "0.5", + "author" => "Starbeamrainbowlabs", + "description" => "Adds proper search functionality to Pepperminty Wiki using an inverted index to provide a full text search engine. If pages don't show up, then you might have hit a stop word. If not, try requesting the `invindex-rebuild` action to rebuild the inverted index from scratch.", + "id" => "feature-search", + "code" => function() { + global $settings; + + /** + * @api {get} ?action=index&page={pageName} Get an index of words for a given page + * @apiName SearchIndex + * @apiGroup Search + * @apiPermission Anonymous + * + * @apiParam {string} page The page to generate a word index page. + */ + + /* + * ██ ███ ██ ██████ ███████ ██ ██ + * ██ ████ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ █████ ███ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ████ ██████ ███████ ██ ██ + */ + add_action("index", function() { + global $settings, $env; + + $breakable_chars = "\r\n\t .,\\/!\"£$%^&*[]()+`_~#"; + + header("content-type: text/plain"); + + $source = file_get_contents("$env->storage_prefix$env->page.md"); + + $index = search::index($source); + + var_dump($env->page); + var_dump($source); + + var_dump($index); + }); + + /** + * @api {get} ?action=invindex-rebuild Rebuild the inverted search index from scratch + * @apiDescription Causes the inverted search index to be completely rebuilt from scratch. Can take a while for large wikis! + * @apiName SearchInvindexRebuild + * @apiGroup Search + * @apiPermission Anonymous + */ + + /* + * ██ ███ ██ ██ ██ ██ ███ ██ ██████ ███████ ██ ██ + * ██ ████ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ███ █████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ████ ████ ██ ██ ████ ██████ ███████ ██ ██ + * + * ██████ ███████ ██████ ██ ██ ██ ██ ██████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ █████ ██████ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ███████ ██████ ██████ ██ ███████ ██████ + */ + add_action("invindex-rebuild", function() { + search::rebuild_invindex(); + }); + + + /** + * @api {get} ?action=idindex-show Show the id index + * @apiDescription Outputs the id index. Useful if you need to verify that it's working as expected. + * @apiName SearchShowIdIndex + * @apiGroup Search + * @apiPermission Anonymous + */ + add_action("idindex-show", function() { + global $idindex; + header("content-type: application/json; charset=UTF-8"); + exit(json_encode($idindex, JSON_PRETTY_PRINT)); + }); + + /** + * @api {get} ?action=search&query={text} Search the wiki for a given query string + * @apiName Search + * @apiGroup Search + * @apiPermission Anonymous + * + * @apiParam {string} query The query string to search for. + */ + + /* + * ███████ ███████ █████ ██████ ██████ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ███████ █████ ███████ ██████ ██ ███████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ███████ ███████ ██ ██ ██ ██ ██████ ██ ██ + */ + add_action("search", function() { + global $settings, $env, $pageindex, $paths; + + // Create the inverted index if it doesn't exist. + // todo In the future perhaps a CLI for this would be good? + if(!file_exists($paths->searchindex)) + search::rebuild_invindex(); + + if(!isset($_GET["query"])) + exit(page_renderer::render("No Search Terms - Error - $settings->sitename", "

    You didn't specify any search terms. Try typing some into the box above.

    ")); + + $search_start = microtime(true); + + $invindex = search::load_invindex($paths->searchindex); + $results = search::query_invindex($_GET["query"], $invindex); + $resultCount = count($results); + + $env->perfdata->search_time = round((microtime(true) - $search_start)*1000, 3); + + $title = $_GET["query"] . " - Search results - $settings->sitename"; + + $content = "
    \n"; + $content .= "

    Search Results

    "; + + /// Search Box /// + $content .= "
    \n"; + $content .= " \n"; + $content .= " \n"; + $content .= "
    "; + + $content .= "

    Found $resultCount " . ($resultCount === 1 ? "result" : "results") . " in " . $env->perfdata->search_time . "ms. "; + + $query = $_GET["query"]; + if(isset($pageindex->$query)) + { + $content .= "There's a page on $settings->sitename called $query."; + } + else + { + $content .= "There isn't a page called $query on $settings->sitename, but you "; + if((!$settings->anonedits && !$env->is_logged_in) || !$settings->editing) + { + $content .= "do not have permission to create it."; + if(!$env->is_logged_in) + { + $content .= " You could try logging in."; + } + } + else + { + $content .= "can create it."; + } + } + $content .= "

    "; + + $i = 0; // todo use $_GET["offset"] and $_GET["result-count"] or something + foreach($results as $result) + { + $link = "?page=" . rawurlencode($result["pagename"]); + $pagesource = file_get_contents($env->storage_prefix . $result["pagename"] . ".md"); + + //echo("Extracting context for result " . $result["pagename"] . ".\n"); + $context = search::extract_context($_GET["query"], $pagesource); + if(strlen($context) === 0) + $context = substr($pagesource, 0, $settings->search_characters_context * 2); + //echo("'Generated search context for " . $result["pagename"] . ": $context'\n"); + $context = search::highlight_context($_GET["query"], $context); + /*if(strlen($context) == 0) + { + $context = search::strip_markup(file_get_contents("$env->page.md", null, null, null, $settings->search_characters_context * 2)); + if($pageindex->{$env->page}->size > $settings->search_characters_context * 2) + $context .= "..."; + }*/ + + // Make redirect pages italics + if(!empty($pageindex->{$result["pagename"]}->redirect)) + $result["pagename"] = "{$result["pagename"]}"; + + // We add 1 to $i here to convert it from an index to a result + // number as people expect it to start from 1 + $content .= "
    \n"; + $content .= "

    " . $result["pagename"] . "

    \n"; + $content .= "

    $context

    \n"; + $content .= "
    \n"; + + $i++; + } + + $content .= "
    \n"; + + header("content-type: text/html; charset=UTF-8"); + exit(page_renderer::render($title, $content)); + + //header("content-type: text/plain"); + //var_dump($results); + }); + +/* + * ██████ ██ ██ ███████ ██████ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ █████ ██████ ████ █████ + * ██ ▄▄ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██████ ███████ ██ ██ ██ + * ▀▀ + * ███████ ███████ █████ ██████ ██████ ██ ██ ██ ███ ██ ██████ ███████ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ + * ███████ █████ ███████ ██████ ██ ███████ ██ ██ ██ ██ ██ ██ █████ ███ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ███████ ███████ ██ ██ ██ ██ ██████ ██ ██ ██ ██ ████ ██████ ███████ ██ ██ + */ + + /** + * @api {get} ?action=query-searchindex&query={text} Inspect the internals of the search results for a query + * @apiName Search + * @apiGroup Search + * @apiPermission Anonymous + * + * @apiParam {string} query The query string to search for. + */ + add_action("query-searchindex", function() { + global $env, $paths; + + if(empty($_GET["query"])) { + http_response_code(400); + header("content-type: text/plain"); + exit("Error: No query specified. Specify it with the 'query' GET parameter."); + } + + $env->perfdata->searchindex_decode_start = microtime(true); + $searchIndex = search::load_invindex($paths->searchindex); + $env->perfdata->searchindex_decode_time = (microtime(true) - $env->perfdata->searchindex_decode_start) * 1000; + $env->perfdata->searchindex_query_start = microtime(true); + $searchResults = search::query_invindex($_GET["query"], $searchIndex); + $env->perfdata->searchindex_query_time = (microtime(true) - $env->perfdata->searchindex_query_start) * 1000; + + header("content-type: application/json"); + $result = new stdClass(); + $result->time_format = "ms"; + $result->decode_time = $env->perfdata->searchindex_decode_time; + $result->query_time = $env->perfdata->searchindex_query_time; + $result->total_time = $result->decode_time + $result->query_time; + $result->search_results = $searchResults; + exit(json_encode($result, JSON_PRETTY_PRINT)); + }); + + /* + * ██████ ██████ ███████ ███ ██ ███████ ███████ █████ ██████ ██████ ██ ██ + * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██████ █████ ██ ██ ██ ███████ █████ ███████ ██████ ██ ███████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██ ███████ ██ ████ ███████ ███████ ██ ██ ██ ██ ██████ ██ ██ + */ + add_action("opensearch-description", function () { + global $settings; + $siteRoot = full_url() . "/index.php"; + if(!isset($_GET["debug"])) + header("content-type: application/opensearchdescription+xml"); + else + header("content-type: text/plain"); + + exit(utf8_encode(" + Search $settings->sitename + Search $settings->sitename, which is powered by Pepperminty Wiki. + $settings->sitename Wiki + $settings->favicon + Search content available under the license linked to at the bottom of the search results page. + Starbeamrainbowlabs (https://github.com/sbrl/Pepperminty-Wiki/graphs/contributors) + UTF-8 + UTF-8 + + +")); + }); + + add_action("suggest-pages", function() { + global $settings, $pageindex; + + if($settings->dynamic_page_suggestion_count === 0) + { + header("content-type: application/json"); + header("content-length: 2"); + exit("[]"); + } + + if(empty($_GET["query"])) { + http_response_code(400); + header("content-type: text/plain"); + exit("Error: You didn't specify the 'query' GET parameter."); + } + + // Rank each page name + $results = []; + foreach($pageindex as $pageName => $entry) { + $results[] = [ + "pagename" => $pageName, + // Costs: Insert: 1, Replace: 8, Delete: 6 + "distance" => levenshtein($_GET["query"], $pageName, 1, 8, 6) + ]; + } + + // Sort the page names by distance form the original query + usort($results, function($a, $b) { + if($a["distance"] == $b["distance"]) + return strcmp($a["pagename"], $b["pagename"]); + return $a["distance"] < $b["distance"] ? -1 : 1; + }); + + // Send the results to the user + header("content-type: application/json"); + exit(json_encode(array_slice($results, 0, $settings->dynamic_page_suggestion_count))); + }); + + if($settings->dynamic_page_suggestion_count > 0) + { + page_renderer::AddJSSnippet('/// Dynamic page suggestion system +// Micro snippet 8 - Promisified GET (fetched 20th Nov 2016) +function get(u){return new Promise(function(r,t,a){a=new XMLHttpRequest();a.onload=function(b,c){b=a.status;c=a.response;if(b>199&&b<300){r(c)}else{t(c)}};a.open("GET",u,true);a.send(null)})} + +window.addEventListener("load", function(event) { + var searchBox = document.querySelector("input[type=search]"); + searchBox.dataset.lastValue = ""; + searchBox.addEventListener("keyup", function(event) { + // Make sure that we don\'t keep sending requests to the server if nothing has changed + if(searchBox.dataset.lastValue == event.target.value) + return; + searchBox.dataset.lastValue = event.target.value; + // Fetch the suggestions from the server + get("?action=suggest-pages&query=" + encodeURIComponent(event.target.value)).then(function(response) { + var suggestions = JSON.parse(response), + dataList = document.getElementById("allpages"); + + // If the server sent no suggestions, then we shouldn\'t replace the contents of the datalist + if(suggestions.length == 0) + return; + + console.info(`Fetched suggestions for ${event.target.value}:`, suggestions.map(s => s.pagename)); + + // Remove all the existing suggestions + while(dataList.firstChild) { + dataList.removeChild(dataList.firstChild); + } + + // Add the new suggestions to the datalist + var optionsFrag = document.createDocumentFragment(); + suggestions.forEach(function(suggestion) { + var suggestionElement = document.createElement("option"); + suggestionElement.value = suggestion.pagename; + suggestionElement.dataset.distance = suggestion.distance; + optionsFrag.appendChild(suggestionElement); + }); + dataList.appendChild(optionsFrag); + }); + }); +}); +'); + } + } +]); + +class search +{ + // Words that we should exclude from the inverted index + public static $stop_words = [ + "a", "about", "above", "above", "across", "after", "afterwards", "again", + "against", "all", "almost", "alone", "along", "already", "also", + "although", "always", "am", "among", "amongst", "amoungst", "amount", + "an", "and", "another", "any", "anyhow", "anyone", "anything", "anyway", + "anywhere", "are", "around", "as", "at", "back", "be", "became", + "because", "become", "becomes", "becoming", "been", "before", + "beforehand", "behind", "being", "below", "beside", "besides", + "between", "beyond", "bill", "both", "bottom", "but", "by", "call", + "can", "cannot", "cant", "co", "con", "could", "couldnt", "cry", "de", + "describe", "detail", "do", "done", "down", "due", "during", "each", + "eg", "eight", "either", "eleven", "else", "elsewhere", "empty", + "enough", "etc", "even", "ever", "every", "everyone", "everything", + "everywhere", "except", "few", "fill", "find", + "fire", "first", "five", "for", "former", "formerly", "found", + "four", "from", "front", "full", "further", "get", "give", "go", "had", + "has", "hasnt", "have", "he", "hence", "her", "here", "hereafter", + "hereby", "herein", "hereupon", "hers", "herself", "him", "himself", + "his", "how", "however", "ie", "if", "in", "inc", "indeed", + "interest", "into", "is", "it", "its", "itself", "keep", "last", + "latter", "latterly", "least", "less", "ltd", "made", "many", "may", + "me", "meanwhile", "might", "mine", "more", "moreover", "most", + "mostly", "move", "much", "must", "my", "myself", "name", "namely", + "neither", "never", "nevertheless", "next", "nine", "no", "none", + "nor", "not", "nothing", "now", "nowhere", "of", "off", "often", "on", + "once", "one", "only", "onto", "or", "other", "others", "otherwise", + "our", "ours", "ourselves", "out", "over", "own", "part", "per", + "perhaps", "please", "put", "rather", "re", "same", "see", "seem", + "seemed", "seeming", "seems", "serious", "several", "she", "should", + "show", "side", "since", "sincere", "six", "sixty", "so", "some", + "somehow", "someone", "something", "sometime", "sometimes", + "somewhere", "still", "such", "system", "take", "ten", "than", "that", + "the", "their", "them", "themselves", "then", "thence", "there", + "thereafter", "thereby", "therefore", "therein", "thereupon", "these", + "they", "thickv", "thin", "third", "this", "those", "though", "three", + "through", "throughout", "thru", "thus", "to", "together", "too", "top", + "toward", "towards", "twelve", "twenty", "two", "un", "under", "until", + "up", "upon", "us", "very", "via", "was", "we", "well", "were", "what", + "whatever", "when", "whence", "whenever", "where", "whereafter", + "whereas", "whereby", "wherein", "whereupon", "wherever", "whether", + "which", "while", "whither", "who", "whoever", "whole", "whom", "whose", + "why", "will", "with", "within", "without", "would", "yet", "you", + "your", "yours", "yourself", "yourselves" + ]; + + public static function index($source) + { + $source = html_entity_decode($source, ENT_QUOTES); + $source_length = strlen($source); + + $index = []; + + $terms = self::tokenize($source); + $i = 0; + foreach($terms as $term) + { + $nterm = $term; + + // Skip over stop words (see https://en.wikipedia.org/wiki/Stop_words) + if(in_array($nterm, self::$stop_words)) continue; + + if(!isset($index[$nterm])) + { + $index[$nterm] = [ "freq" => 0, "offsets" => [] ]; + } + + $index[$nterm]["freq"]++; + $index[$nterm]["offsets"][] = $i; + + $i++; + } + + return $index; + } + + public static function tokenize($source) + { + $source = strtolower($source); + $source = str_replace([ '[', ']', '|', '{', '}' ], " ", $source); + return preg_split("/((^\p{P}+)|(\p{P}*\s+\p{P}*)|(\p{P}+$))|\|/u", $source, -1, PREG_SPLIT_NO_EMPTY); + } + + public static function strip_markup($source) + { + return str_replace([ "[", "]", "\"", "*", "_", " - ", "`" ], "", $source); + } + + public static function rebuild_invindex() + { + global $pageindex, $env, $paths; + + header("content-type: text/event-stream"); + + // Clear the id index out + ids::clear(); + + // Reindex each page in turn + $invindex = []; + foreach($pageindex as $pagename => $pagedetails) + { + $pagesource = utf8_encode(file_get_contents("$env->storage_prefix$pagename.md")); + $index = self::index($pagesource); + + $pageid = ids::getid($pagename); + self::merge_into_invindex($invindex, $pageid, $index); + + echo("Added $pagename (id #$pageid) to the new search index.\n\n"); + flush(); + } + + echo("Search index rebuilding complete.\n\n"); + echo("Saving new search index to '$paths->searchindex'.\n\n"); + + self::save_invindex($paths->searchindex, $invindex); + } + + /* + * @summary Sorts an index alphabetically. Will also sort an inverted index. + * This allows us to do a binary search instead of a regular + * sequential search. + */ + public static function sort_index(&$index) + { + ksort($index, SORT_NATURAL); + } + + /* + * @summary Compares two *regular* indexes to find the differences between them. + * + * @param {array} $indexa - The old index. + * @param {array} $indexb - The new index. + * @param {array} $changed - An array to be filled with the nterms of all + * the changed entries. + * @param {array} $removed - An array to be filled with the nterms of all + * the removed entries. + */ + public static function compare_indexes($oldindex, $newindex, &$changed, &$removed) + { + foreach($oldindex as $nterm => $entry) + { + if(!isset($newindex[$nterm])) + $removed[] = $nterm; + } + foreach($newindex as $nterm => $entry) + { + if(!isset($oldindex[$nterm]) or // If this world is new + $newindex[$nterm] !== $oldindex[$nterm]) // If this word has changed + $changed[$nterm] = $newindex[$nterm]; + } + } + + /* + * @summary Reads in and parses an inverted index. + */ + // Todo remove this function and make everything streamable + public static function load_invindex($invindex_filename) { + $invindex = json_decode(file_get_contents($invindex_filename), true); + return $invindex; + } + + public static function measure_invindex_load_time($invindex_filename) { + global $env; + + $searchindex_decode_start = microtime(true); + search::load_invindex($invindex_filename); + $env->perfdata->searchindex_decode_time = round((microtime(true) - $searchindex_decode_start)*1000, 3); + } + + /* + * @summary Merge an index into an inverted index. + */ + public static function merge_into_invindex(&$invindex, $pageid, &$index, &$removals = []) + { + // Remove all the subentries that were removed since last time + foreach($removals as $nterm) + { + unset($invindex[$nterm][$pageid]); + } + + // Merge all the new / changed index entries into the inverted index + foreach($index as $nterm => $newentry) + { + // If the nterm isn't in the inverted index, then create a space for it + if(!isset($invindex[$nterm])) $invindex[$nterm] = []; + $invindex[$nterm][$pageid] = $newentry; + + // Sort the page entries for this word by frequency + uasort($invindex[$nterm], function($a, $b) { + if($a["freq"] == $b["freq"]) return 0; + return ($a["freq"] < $b["freq"]) ? +1 : -1; + }); + } + + // Sort the inverted index by rank + uasort($invindex, function($a, $b) { + $ac = count($a); $bc = count($b); + if($ac == $bc) return 0; + return ($ac < $bc) ? +1 : -1; + }); + } + + /** + * Deletes the given pageid from the given pageindex. + * @param inverted_index &$invindex The inverted index. + * @param number $pageid The pageid to remove. + */ + public static function delete_entry(&$invindex, $pageid) + { + $str_pageid = (string)$pageid; + foreach($invindex as $nterm => &$entry) + { + if(isset($entry[$pageid])) + unset($entry[$pageid]); + if(isset($entry[$str_pageid])) + unset($entry[$str_pageid]); + if(count($entry) === 0) + unset($invindex[$nterm]); + } + } + + public static function save_invindex($filename, &$invindex) + { + file_put_contents($filename, json_encode($invindex)); + } + + public static function query_invindex($query, &$invindex) + { + global $settings, $pageindex; + + $query_terms = self::tokenize($query); + $matching_pages = []; + + + // Loop over each term in the query and find the matching page entries + $count = count($query_terms); + for($i = 0; $i < $count; $i++) + { + $qterm = $query_terms[$i]; + + // Stop words aren't worth the bother - make sure we don't search + // the title or the tags for them + if(in_array($qterm, self::$stop_words)) + continue; + + // Only search the inverted index if it actually exists there + if(isset($invindex[$qterm])) + { + // Loop over each page in the inverted index entry + foreach($invindex[$qterm] as $pageid => $page_entry) + { + // Create an entry in the matching pages array if it doesn't exist + if(!isset($matching_pages[$pageid])) + $matching_pages[$pageid] = [ "nterms" => [] ]; + $matching_pages[$pageid]["nterms"][$qterm] = $page_entry; + } + } + + + // Loop over the pageindex and search the titles / tags + foreach ($pageindex as $pagename => $pagedata) + { + // Get the current page's id + $pageid = ids::getid($pagename); + // Consider matches in the page title + if(stripos($pagename, $qterm) !== false) + { + // We found the qterm in the title + if(!isset($matching_pages[$pageid])) + $matching_pages[$pageid] = [ "nterms" => [] ]; + + // Set up a counter for page title matches if it doesn't exist already + if(!isset($matching_pages[$pageid]["title-matches"])) + $matching_pages[$pageid]["title-matches"] = 0; + + $matching_pages[$pageid]["title-matches"] += count(mb_stripos_all($pagename, $qterm)); + } + + // Consider matches in the page's tags + if(isset($pagedata->tags) and // If this page has tags + stripos(implode(" ", $pagedata->tags), $qterm) !== false) // And we found the qterm in the tags + { + if(!isset($matching_pages[$pageid])) + $matching_pages[$pageid] = [ "nterms" => [] ]; + + // Set up a counter for tag match if there isn't one already + if(!isset($matching_pages[$pageid]["tag-matches"])) + $matching_pages[$pageid]["tag-matches"] = 0; + $matching_pages[$pageid]["tag-matches"] += count(mb_stripos_all(implode(" ", $pagedata->tags), $qterm)); + } + } + } + + + foreach($matching_pages as $pageid => &$pagedata) + { + $pagedata["pagename"] = ids::getpagename($pageid); + $pagedata["rank"] = 0; + + $pageOffsets = []; + + // Loop over each search term found on this page + foreach($pagedata["nterms"] as $pterm => $entry) + { + // Add the number of occurrences of this search term to the ranking + $pagedata["rank"] += $entry["freq"]; + + // Add the offsets to a listof all offsets on this page + foreach($entry["offsets"] as $offset) + $pageOffsets[] = $offset; + } + /* + // Sort the list of offsets + $pageOffsets = array_unique($pageOffsets); + sort($pageOffsets); + var_dump($pageOffsets); + + // Calcualate the clump distances via a variable moving window size + $pageOffsetsCount = count($pageOffsets); + $clumpDistanceWindow = min($count, $pageOffsetsCount); // a.k.a. count($query_terms) - see above + $clumpDistances = []; + for($i = 0; $i < $pageOffsetsCount - $clumpDistanceWindow; $i++) + $clumpDistances[] = $pageOffsets[$i] - $pageOffsets[$i + $clumpDistanceWindow]; + + // Sort the new list of clump distances + sort($clumpDistances); + // Calcualate a measureof how clumped the offsets are + $tightClumpLimit = floor((count($clumpDistances) - 1) / 0.25); + $tightClumpsMeasure = $clumpDistances[$tightClumpLimit] - $clumpDistances[0]; + $clumpsRange = $clumpDistances[count($clumpDistances) - 1] - $clumpDistances[0]; + + $clumpiness = $tightClumpsMeasure / $clumpsRange; + echo("{$pagedata["pagename"]} - $clumpiness"); + */ + + // Consider matches in the title / tags + if(isset($pagedata["title-matches"])) + $pagedata["rank"] += $pagedata["title-matches"] * $settings->search_title_matches_weighting; + if(isset($pagedata["tag-matches"])) + $pagedata["rank"] += $pagedata["tag-matches"] * $settings->search_tags_matches_weighting; + + // todo remove items if the rank is below a threshold + } + + // todo sort by rank here + uasort($matching_pages, function($a, $b) { + if($a["rank"] == $b["rank"]) return 0; + return ($a["rank"] < $b["rank"]) ? +1 : -1; + }); + + return $matching_pages; + } + + public static function extract_context($query, $source) + { + global $settings; + + $nterms = self::tokenize($query); + $matches = []; + // Loop over each nterm and find it in the source + foreach($nterms as $nterm) + { + if(in_array($nterm, static::$stop_words)) + continue; + $all_offsets = mb_stripos_all($source, $nterm); + // Skip over adding matches if there aren't any + if($all_offsets === false) + continue; + foreach($all_offsets as $offset) + { + $matches[] = [ $nterm, $offset ]; + } + } + + // Sort the matches by offset + usort($matches, function($a, $b) { + if($a[1] == $b[1]) return 0; + return ($a[1] > $b[1]) ? +1 : -1; + }); + + $sourceLength = strlen($source); + + $contexts = []; + $basepos = 0; + $matches_count = count($matches); + while($basepos < $matches_count) + { + // Store the next match along - all others will be relative to that one + $group = [$matches[$basepos]]; + + // Start scanning at the next one along - we always store the first match + $scanpos = $basepos + 1; + $distance = 0; + + while(true) + { + // Break out if we reach the end + if($scanpos >= $matches_count) break; + + // Find the distance between the current one and the last one + $distance = $matches[$scanpos][1] - $matches[$scanpos - 1][1]; + + // Store it if the distance is below the threshold + if($distance < $settings->search_characters_context) + $group[] = $matches[$scanpos]; + else + break; + + $scanpos++; + } + + $context_start = $group[0][1] - $settings->search_characters_context; + $context_end = $group[count($group) - 1][1] + $settings->search_characters_context; + + if($context_start < 0) $context_start = 0; + if($context_end > $sourceLength) $context_end = $sourceLength; + + //echo("Got context. Start: $context_start, End: $context_end\n"); + //echo("Group:"); var_dump($group); + + $context = substr($source, $context_start, $context_end - $context_start); + + // Strip the markdown from the context - it's most likely going to + // be broken anyway. + //$context = self::strip_markup($context); + + // Escape special characters to protect against attacks + $context = htmlentities($context); + + $contexts[] = $context; + + $basepos = $scanpos + 1; + } + + return implode(" ... ", $contexts); + } + + public static function highlight_context($query, $context) + { + $qterms = self::tokenize($query); + + foreach($qterms as $qterm) + { + if(in_array($qterm, static::$stop_words)) + continue; + // From http://stackoverflow.com/a/2483859/1460422 + $context = preg_replace("/" . str_replace("/", "\/", preg_quote($qterm)) . "/i", "$0", $context); + } + + return $context; + } +} + + + + +register_module([ + "name" => "Uploader", + "version" => "0.5.8", + "author" => "Starbeamrainbowlabs", + "description" => "Adds the ability to upload files to Pepperminty Wiki. Uploaded files act as pages and have the special 'File/' prefix.", + "id" => "feature-upload", + "code" => function() { + global $settings; + /** + * @api {get} ?action=upload Get a page to let you upload a file. + * @apiName UploadFilePage + * @apiGroup Upload + * @apiPermission User + */ + + /** + * @api {post} ?action=upload Upload a file + * @apiName UploadFile + * @apiGroup Upload + * @apiPermission User + * + * @apiParam {string} name The name of the file to upload. + * @apiParam {string} description A description of the file. + * @apiParam {file} file The file to upload. + * + * @apiUse UserNotLoggedInError + * @apiError UploadsDisabledError Uploads are currently disabled in the wiki's settings. + * @apiError UnknownFileTypeError The type of the file you uploaded is not currently allowed in the wiki's settings. + * @apiError ImageDimensionsFiledError PeppermintyWiki couldn't obtain the dimensions of the image you uploaded. + * @apiError DangerousFileError The file uploaded appears to be dangerous. + * @apiError DuplicateFileError The filename specified is a duplicate of a file that already exists. + * @apiError FileTamperedError Pepperminty Wiki couldn't verify that the file wasn't tampered with during theupload process. + */ + + /* + * ██ ██ ██████ ██ ██████ █████ ██████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██████ ██ ██ ██ ███████ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ██ ███████ ██████ ██ ██ ██████ + */ + add_action("upload", function() { + global $settings, $env, $pageindex, $paths; + + switch($_SERVER["REQUEST_METHOD"]) + { + case "GET": + // Send upload page + + if(!$settings->upload_enabled) + exit(page_renderer::render("Upload Disabled - $setting->sitename", "

    You can't upload anything at the moment because $settings->sitename has uploads disabled. Try contacting $settings->admindetails_name, your site Administrator. Go back.

    ")); + if(!$env->is_logged_in) + exit(page_renderer::render("Upload Error - $settings->sitename", "

    You are not currently logged in, so you can't upload anything.

    +

    Try logging in first.

    ")); + + exit(page_renderer::render("Upload - $settings->sitename", "

    Upload file

    +

    Select an image below, and then type a name for it in the box. This server currently supports uploads up to " . human_filesize(get_max_upload_size()) . " in size.

    +

    $settings->sitename currently supports uploading of the following file types: " . implode(", ", $settings->upload_allowed_file_types) . ".

    +
    + + +
    + + +
    + + +

    $settings->editing_message

    + +
    + ")); + + break; + + case "POST": + // Recieve file + + // Make sure uploads are enabled + if(!$settings->upload_enabled) + { + 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) + { + 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.")); + } + + // Calculate the target name, removing any characters we + // are unsure about. + $target_name = makepathsafe($_POST["name"]); + $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 + switch(substr($mime_type, 0, strpos($mime_type, "/"))) + { + case "image": + $extra_data = []; + // Check SVG uploads with a special function + $imagesize = $mime_type !== "image/svg+xml" ? getimagesize($temp_filename, $extra_data) : upload_check_svg($temp_filename); + + // Make sure that the image size is defined + if(!is_int($imagesize[0]) or !is_int($imagesize[1])) + { + http_response_code(415); + exit(page_renderer::render("Upload Error - $settings->sitename", "

    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" ])) + { + 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.

    ")); + } + + $new_filename = "$paths->upload_file_prefix$target_name.$file_extension"; + $new_description_filename = "$new_filename.md"; + + if(isset($pageindex->$new_filename)) + exit(page_renderer::render("Upload Error - $settings->sitename", "

    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.

    ")); + + if(!file_exists($env->storage_prefix . "Files")) + mkdir($env->storage_prefix . "Files", 0775); + + 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 $settings->sitename has been attacked. Please contact " . $settings->admindetails_name . ", your $settings->sitename Administrator.

    ")); + } + + $description = $_POST["description"]; + + // 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); + $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_filename = $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_filename, + "user" => $env->user, + "filesize" => filesize($env->storage_prefix . $entry->uploadedfilepath) + ]); + } + + header("location: ?action=view&page=$new_filename&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 = $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\""); + + // If the size is set or 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"; + + $preview_sizes = [ 256, 512, 768, 1024, 1440 ]; + $preview_html .= "\t\t\t
    + + \n\t\t\t
    "; + break; + + case "video": + $preview_html .= "\t\t\t
    + +
    "; + break; + + case "audio": + $preview_html .= "\t\t\t
    + +
    "; + } + + $fileInfo = []; + $fileInfo["Name"] = str_replace("File/", "", $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; + + $preview_html .= "\t\t\t

    File Information

    + "; + foreach ($fileInfo as $displayName => $displayValue) + { + $preview_html .= "\n"; + } + $preview_html .= "
    $displayName$displayValue
    "; + + $parts["{content}"] = str_replace("", "\n$preview_html", $parts["{content}"]); + } + }); + + // Register a section on the help page on uploading files + add_help_section("28-uploading-files", "Uploading Files", "

    $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.

    "); + } +]); + +//// Pair of functions to calculate the actual maximum upload size supported by the server +//// Lifted from Drupal by @meustrus from Stackoverflow. Link to answer: +//// http://stackoverflow.com/a/25370978/1460422 +// Returns a file size limit in bytes based on the PHP upload_max_filesize +// and post_max_size +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; +} + +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); + } +} + +function upload_check_svg($temp_filename) +{ + global $settings; + // Check for script tags + if(strpos(file_get_contents($temp_filename), "sitename", "

    $settings->sitename detected that you uploaded an SVG image and performed some extra security checks on your file. Whilst performing these checks it was discovered that the file you uploaded contains some Javascript, which could be dangerous. 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.

    ")); + } + + // Find and return the size of the SVG image + return getsvgsize($temp_filename); +} + +function getsvgsize($svgFilename) +{ + $svg = simplexml_load_file($svgFilename); // Load it as XML + if($svg === false) + { + http_response_code(415); + exit(page_renderer::render("Upload Error - $settings->sitename", "

    When $settings->sitename tried to open your SVG file for checking, it found some invalid syntax. The uploaded file has been discarded. Go back to try again.

    ")); + } + $rootAttrs = $svg->attributes(); + $imageSize = false; + if(isset($rootAttrs->width) and isset($rootAttrs->height)) + $imageSize = [ intval($rootAttrs->width), intval($rootAttrs->height) ]; + else if(isset($rootAttrs->viewBox)) + $imageSize = array_map("intval", array_slice(explode(" ", $rootAttrs->viewBox), -2, 2)); + + return $imageSize; +} + +function errorimage($text, $target_size = null) +{ + $width = 640; + $height = 480; + + if(!empty($target_size)) + { + $width = $target_size; + $height = $target_size * (2 / 3); + } + + $image = imagecreatetruecolor($width, $height); + imagefill($image, 0, 0, imagecolorallocate($image, 238, 232, 242)); // Set the background to #eee8f2 + $fontwidth = imagefontwidth(3); + imagestring($image, 3, + ($width / 2) - (($fontwidth * strlen($text)) / 2), + ($height / 2) - (imagefontheight(3) / 2), + $text, + imagecolorallocate($image, 17, 17, 17) // #111111 + ); + + return $image; +} + + + + +register_module([ + "name" => "User Preferences", + "version" => "0.2.1", + "author" => "Starbeamrainbowlabs", + "description" => "Adds a user preferences page, letting pople do things like change their email address and password.", + "id" => "feature-user-preferences", + "code" => function() { + global $env, $settings; + /** + * @api {get} ?action=user-preferences Get a user preferences configuration page. + * @apiName UserPreferences + * @apiGroup Settings + * @apiPermission User + */ + + /* + * ██ ██ ███████ ███████ ██████ + * ██ ██ ██ ██ ██ ██ + * ██ ██ ███████ █████ ██████ █████ + * ██ ██ ██ ██ ██ ██ + * ██████ ███████ ███████ ██ ██ + * + * ██████ ██████ ███████ ███████ ███████ + * ██ ██ ██ ██ ██ ██ ██ + * ██████ ██████ █████ █████ ███████ + * ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ███████ ██ ███████ + */ + add_action("user-preferences", function() { + global $env, $settings; + + if(!$env->is_logged_in) + { + exit(page_renderer::render_main("Error - $settings->sitename", "

    Since you aren't logged in, you can't change your preferences. This is because stored preferences are tied to each registered user account. You can login here.

    ")); + } + + $statusMessages = [ + "change-password" => "Password changed successfully!" + ]; + + if(!isset($env->user_data->emailAddress)) { + $env->user_data->emailAddress = ""; + save_userdata(); + } + + $content = "

    User Preferences

    \n"; + if(isset($_GET["success"]) && $_GET["success"] === "yes") + { + $content .= "

    " . $statusMessages[$_GET["operation"]] . "

    \n"; + } + $content .= "\n"; + $content .= "\n"; + $content .= "
    \n"; + $content .= " \n"; + $content .= " \n"; + $content .= "

    Used to send you notifications etc. Never shared with anyone except $settings->admindetails_name, $settings->sitename's administrator.

    \n"; + $content .= " \n"; + $content .= "
    \n"; + $content .= "

    Change Password"; + $content .= "
    \n"; + $content .= " \n"; + $content .= " \n"; + $content .= "
    \n"; + $content .= " \n"; + $content .= " \n"; + $content .= "
    \n"; + $content .= " \n"; + $content .= " \n"; + $content .= "
    \n"; + $content .= " \n"; + $content .= "
    \n"; + + if($env->is_admin) + $content .= "

    As an admin, you can also edit $settings->sitename's master settings.

    \n"; + + exit(page_renderer::render_main("User Preferences - $settings->sitename", $content)); + }); + + add_action("save-preferences", function() { + global $env, $settings; + + if(!$env->is_logged_in) + { + http_response_code(400); + exit(page_renderer::render_main("Error Saving Preferences - $settings->sitename", "

    You aren't logged in, so you can't save your preferences. Try logging in first.

    ")); + } + + if(isset($_POST["email-address"])) + { + if(mb_strlen($_POST["email-address"]) > 320) + { + http_response_code(413); + exit(page_renderer::render_main("Error Saving Email Address - $settings->sitename", "

    The email address you supplied ({$_POST['email-address']}) is too long. Email addresses can only be 320 characters long. Go back.")); + } + + if(mb_strpos($_POST["email-address"], "@") === false) + { + http_response_code(422); + exit(page_renderer::render_main("Error Saving Email Address - $settings->sitename", "

    The email address you supplied ({$_POST['email-address']}) doesn't appear to be valid. Go back.")); + } + + $env->user_data->emailAddress = $_POST["email-address"]; + } + + // Save the user's preferences + if(!save_userdata()) + { + http_response_code(503); + exit(page_renderer::render_main("Error Saving Preferences - $settings->sitename", "

    $settings->sitename had some trouble saving your preferences! Please contact $settings->admindetails_name, $settings->sitename's administrator and tell them about this error if it still occurs in 5 minutes. They can be contacted by email at this address: " . hide_email($settings->admindetails_email) . ".

    ")); + } + + exit(page_renderer::render_main("Preferences Saved Successfully - $settings->sitename", "

    Your preferences have been saved successfully! You could go back your preferences page, or on to the $settings->defaultpage.

    ")); + }); + + /** + * @api {post} ?action=change-password Change your password + * @apiName ChangePassword + * @apiGroup Settings + * @apiPermission User + * + * @apiParam {string} current-pass Your current password. + * @apiParam {string} new-pass Your new password. + * @apiParam {string} new-pass-confirm Your new password again, to make sure you've typed it correctly. + * + * @apiError PasswordMismatchError The new password fields don't match. + */ + add_action("change-password", function() { + global $env, $settings; + + // Make sure the new password was typed correctly + // This comes before the current password check since that's more intensive + if($_POST["new-pass"] !== $_POST["new-pass-confirm"]) { + exit(page_renderer::render_main("Password mismatch - $settings->sitename", "

    The new password you typed twice didn't match! Go back.

    ")); + } + // Check the current password + if(hash_password($_POST["current-pass"]) !== $env->user_data->password) { + exit(page_renderer::render_main("Password mismatch - $settings->sitename", "

    Error: You typed your current password incorrectly! Go back.

    ")); + } + + // All's good! Go ahead and change the password. + $env->user_data->password = hash_password($_POST["new-pass"]); + // Save the userdata back to disk + save_userdata(); + + http_response_code(307); + header("location: ?action=user-preferences&success=yes&operation=change-password"); + exit(page_renderer::render_main("Password Changed Successfully", "

    You password was changed successfully. Go back to the user preferences page.

    ")); + }); + + // Display a help section on the user preferences, but only if the user + // is logged in and so able to access them + if($env->is_logged_in) + { + add_help_section("910-user-preferences", "User Preferences", "

    As you are logged in, $settings->sitename lets you configure a selection of personal preferences. These can be viewed and tweaked to you liking over on the preferences page, which can be accessed at any time by clicking the cog icon (it looks something like this: $settings->user_preferences_button_text), though the administrator of $settings->sitename ($settings->admindetails_name) may have changed its appearance.

    "); + } + } +]); + + + register_module([ "name" => "Credits", @@ -4651,84 +4648,84 @@ register_module([ ]); - - -register_module([ - "name" => "Debug Information", - "version" => "0.1.1", - "author" => "Starbeamrainbowlabs", - "description" => "Adds a debug action for administrator use only that collects a load of useful information to make reporting bugs easier.", - "id" => "page-debug-info", - "code" => function() { - global $settings, $env; - /** - * @api {get} ?action=debug Get a debug dump - * @apiName Debug - * @apiGroup Utility - * @apiPermission Moderator - * - * @apiUse UserNotModeratorError - */ - - /* - * ██████ ███████ ██████ ██ ██ ██████ - * ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ █████ ██████ ██ ██ ██ ███ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ███████ ██████ ██████ ██████ - */ - add_action("debug", function() { - global $settings, $env, $paths, $version; - header("content-type: text/plain"); - - if(!$env->is_admin) - { - exit("You must be logged in as an moderator in order to generate debugging information."); - } - - $title = "$settings->sitename debug report"; - echo("$title\n"); - echo(str_repeat("=", strlen($title)) . "\n"); - echo("Powered by Pepperminty Wiki version $version.\n"); - echo("This report may contain personal information.\n\n"); - echo("Environment: "); - echo(var_export($env, true)); - echo("\nPaths: "); - var_dump(var_export($paths, true)); - echo("\nServer information:\n"); - echo("uname -a: " . php_uname() . "\n"); - echo("Path: " . getenv("PATH") . "\n"); - echo("Temporary directory: " . sys_get_temp_dir() . "\n"); - echo("Server: " . $_SERVER["SERVER_SOFTWARE"] . "\n"); - echo("Web root: " . $_SERVER["DOCUMENT_ROOT"] . "\n"); - echo("Web server user: " . exec("whoami") . "\n"); - echo("PHP version: " . phpversion() . "\n"); - echo("index.php location: " . __FILE__ . "\n"); - echo("index.php file permissions: " . substr(sprintf('%o', fileperms("./index.php")), -4) . "\n"); - echo("Current folder permissions: " . substr(sprintf('%o', fileperms(".")), -4) . "\n"); - echo("Storage directory permissions: " . substr(sprintf('%o', fileperms($env->storage_prefix)), -4) . "\n"); - echo("Loaded extensions: " . implode(", ", get_loaded_extensions()) . "\n"); - echo("Settings:\n-----\n"); - $settings_export = explode("\n", var_export($settings, true)); - foreach ($settings_export as &$row) - { - if(preg_match("/(sitesecret|email)/i", $row)) $row = "********* secret *********"; - } - echo(implode("\n", $settings_export)); - echo("\n-----\n"); - exit(); - }); - - if($env->is_admin) - { - add_help_section("950-debug-information", "Gathering debug information", "

    As a moderator, $settings->sitename gives you the ability to generate a report on $settings->sitename's installation of Pepperminty Wiki for debugging purposes.

    -

    To generate such a report, visit the debug action or click here.

    "); - } - } -]); - - - + + +register_module([ + "name" => "Debug Information", + "version" => "0.1.1", + "author" => "Starbeamrainbowlabs", + "description" => "Adds a debug action for administrator use only that collects a load of useful information to make reporting bugs easier.", + "id" => "page-debug-info", + "code" => function() { + global $settings, $env; + /** + * @api {get} ?action=debug Get a debug dump + * @apiName Debug + * @apiGroup Utility + * @apiPermission Moderator + * + * @apiUse UserNotModeratorError + */ + + /* + * ██████ ███████ ██████ ██ ██ ██████ + * ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ █████ ██████ ██ ██ ██ ███ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ███████ ██████ ██████ ██████ + */ + add_action("debug", function() { + global $settings, $env, $paths, $version; + header("content-type: text/plain"); + + if(!$env->is_admin) + { + exit("You must be logged in as an moderator in order to generate debugging information."); + } + + $title = "$settings->sitename debug report"; + echo("$title\n"); + echo(str_repeat("=", strlen($title)) . "\n"); + echo("Powered by Pepperminty Wiki version $version.\n"); + echo("This report may contain personal information.\n\n"); + echo("Environment: "); + echo(var_export($env, true)); + echo("\nPaths: "); + var_dump(var_export($paths, true)); + echo("\nServer information:\n"); + echo("uname -a: " . php_uname() . "\n"); + echo("Path: " . getenv("PATH") . "\n"); + echo("Temporary directory: " . sys_get_temp_dir() . "\n"); + echo("Server: " . $_SERVER["SERVER_SOFTWARE"] . "\n"); + echo("Web root: " . $_SERVER["DOCUMENT_ROOT"] . "\n"); + echo("Web server user: " . exec("whoami") . "\n"); + echo("PHP version: " . phpversion() . "\n"); + echo("index.php location: " . __FILE__ . "\n"); + echo("index.php file permissions: " . substr(sprintf('%o', fileperms("./index.php")), -4) . "\n"); + echo("Current folder permissions: " . substr(sprintf('%o', fileperms(".")), -4) . "\n"); + echo("Storage directory permissions: " . substr(sprintf('%o', fileperms($env->storage_prefix)), -4) . "\n"); + echo("Loaded extensions: " . implode(", ", get_loaded_extensions()) . "\n"); + echo("Settings:\n-----\n"); + $settings_export = explode("\n", var_export($settings, true)); + foreach ($settings_export as &$row) + { + if(preg_match("/(sitesecret|email)/i", $row)) $row = "********* secret *********"; + } + echo(implode("\n", $settings_export)); + echo("\n-----\n"); + exit(); + }); + + if($env->is_admin) + { + add_help_section("950-debug-information", "Gathering debug information", "

    As a moderator, $settings->sitename gives you the ability to generate a report on $settings->sitename's installation of Pepperminty Wiki for debugging purposes.

    +

    To generate such a report, visit the debug action or click here.

    "); + } + } +]); + + + register_module([ "name" => "Page deleter", @@ -4840,7 +4837,7 @@ register_module([ ]); - + register_module([ "name" => "Page editor", @@ -4931,7 +4928,7 @@ register_module([ exit(page_renderer::render_main("Viewing source for $env->page", $sourceViewContent)); } else - { + { $errorMessage = "

    The page $env->page does not exist, but you do not have permission to create it.

    If you haven't already, perhaps you should try logging in.

    \n"; if($isOtherUsersPage) { @@ -5235,7 +5232,7 @@ DIFFSCRIPT; ]); - + register_module([ "name" => "Export", @@ -5312,7 +5309,7 @@ register_module([ ]); - + register_module([ "name" => "Help page", @@ -5466,7 +5463,7 @@ register_module([ ]); - + register_module([ "name" => "Page list", @@ -5614,7 +5611,7 @@ function generate_page_list($pagelist) } - + register_module([ "name" => "Login", @@ -5774,7 +5771,7 @@ function hash_password($pass) } - + register_module([ "name" => "Logout", @@ -5815,7 +5812,7 @@ register_module([ ]); - + register_module([ "name" => "Page mover", @@ -5947,7 +5944,7 @@ register_module([ ]); - + register_module([ "name" => "Update", @@ -6036,56 +6033,56 @@ register_module([ } ]); - - -register_module([ - "name" => "User list", - "version" => "0.1", - "author" => "Starbeamrainbowlabs", - "description" => "Adds a 'user-list' action that generates a list of users. Supports json output with 'format=json' in the queyr string.", - "id" => "page-user-list", - "code" => function() { - global $settings; - /** - * @api {get} ?action=user-list[format=json] List all users - * @apiName UserList - * @apiGroup Utility - * @apiPermission Anonymous - */ - - /* - * ██ ██ ███████ ███████ ██████ ██ ██ ███████ ████████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ███████ █████ ██████ █████ ██ ██ ███████ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██████ ███████ ███████ ██ ██ ███████ ██ ███████ ██ - */ - add_action("user-list", function() { - global $env, $settings; - - $userList = array_keys(get_object_vars($settings->users)); - if(!empty($_GET["format"]) && $_GET["format"] === "json") - { - header("content-type: application/json"); - exit(json_encode($userList)); - } - - $content = "

    User List

    \n"; - $content .= "
      \n"; - foreach($userList as $username) - $content .= "\t
    • " . page_renderer::render_username($username) . "
    • \n"; - $content .= "
    \n"; - - exit(page_renderer::render_main("User List - $settings->sitename", $content)); - }); - - 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.

    "); - } -]); - - - + + +register_module([ + "name" => "User list", + "version" => "0.1", + "author" => "Starbeamrainbowlabs", + "description" => "Adds a 'user-list' action that generates a list of users. Supports json output with 'format=json' in the queyr string.", + "id" => "page-user-list", + "code" => function() { + global $settings; + /** + * @api {get} ?action=user-list[format=json] List all users + * @apiName UserList + * @apiGroup Utility + * @apiPermission Anonymous + */ + + /* + * ██ ██ ███████ ███████ ██████ ██ ██ ███████ ████████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ███████ █████ ██████ █████ ██ ██ ███████ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██████ ███████ ███████ ██ ██ ███████ ██ ███████ ██ + */ + add_action("user-list", function() { + global $env, $settings; + + $userList = array_keys(get_object_vars($settings->users)); + if(!empty($_GET["format"]) && $_GET["format"] === "json") + { + header("content-type: application/json"); + exit(json_encode($userList)); + } + + $content = "

    User List

    \n"; + $content .= "
      \n"; + foreach($userList as $username) + $content .= "\t
    • " . page_renderer::render_username($username) . "
    • \n"; + $content .= "
    \n"; + + exit(page_renderer::render_main("User List - $settings->sitename", $content)); + }); + + 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.

    "); + } +]); + + + register_module([ "name" => "Page viewer", @@ -6259,617 +6256,617 @@ register_module([ ]); - - -register_module([ - "name" => "Parsedown", - "version" => "0.9.9", - "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; - if($settings->clean_raw_html) - $parser->setMarkupEscaped(true); - else - $parser->setMarkupEscaped(false); - $result = $parser->text($source); - - return $result; - }); - - add_help_section("20-parser-default", "Editor Syntax", - "

    $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!

    -

    Tips

    -
      -
    • Put 2 spaces at the end of a line to add a soft line break. Leave a blank line to add a head line break (i.e. a new paragraph).
    • -
    • You can add an id to a header that you can link to. Put it in curly braces after the heading name like this: # 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]].
    • -
    -

    Extra Syntax

    -

    $settings->sitename's editor also supports some extra custom syntax, some of which is inspired by Mediawiki. - - - - - - - -
    Type thisTo get thisComments
    [[Internal link]]Internal LinkAn internal link.
    [[Display Text|Internal link]]Display TextAn internal link with some display text.
    ![Alt text](http://example.com/path/to/image.png | 256x256 | right)Alt textAn 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)
    Alt text
    Alt text
    An image with a caption that fits inside a 256px x 256px box, preserving aspect ratio. The caption is taken from the alt text.
    ![Alt text](Files/Cheese.png)Alt textAn 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.

    -

    Templating

    -

    $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.

    -
    Special Variables
    -

    $settings->sitename also supports a number of special built-in variables. Their syntax and function are described below:

    - - - - - - - -
    Type thisTo 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.
    "); - } -]); - -/*** Parsedown versions *** - * Parsedown Core: 1.6.0 * - * Parsedown Extra: 0.7.0 * - **************************/ -$env->parsedown_paths = new stdClass(); -$env->parsedown_paths->parsedown = "https://cdn.rawgit.com/erusev/parsedown/3ebbd730b5c2cf5ce78bc1bf64071407fc6674b7/Parsedown.php"; -$env->parsedown_paths->parsedown_extra = "https://cdn.rawgit.com/erusev/parsedown-extra/11a44e076d02ffcc4021713398a60cd73f78b6f5/ParsedownExtra.php"; - -// Download parsedown and parsedown extra if they don't already exist -if(!file_exists("./Parsedown.php") || filesize("./Parsedown.php") === 0) - file_put_contents("./Parsedown.php", fopen($env->parsedown_paths->parsedown, "r")); -if(!file_exists("./ParsedownExtra.php") || filesize("./ParsedownExtra.php") === 0) - file_put_contents("./ParsedownExtra.php", fopen($env->parsedown_paths->parsedown_extra, "r")); - -require_once("./Parsedown.php"); -require_once("./ParsedownExtra.php"); - -/* - * ██████ █████ ██████ ███████ ███████ ██████ ██████ ██ ██ ███ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ - * ██████ ███████ ██████ ███████ █████ ██ ██ ██ ██ ██ █ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ███████ ███████ ██████ ██████ ███ ███ ██ ████ - * - * ███████ ██ ██ ████████ ███████ ███ ██ ███████ ██ ██████ ███ ██ ███████ - * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ████ ██ ██ - * █████ ███ ██ █████ ██ ██ ██ ███████ ██ ██ ██ ██ ██ ██ ███████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ███████ ██ ██ ██ ███████ ██ ████ ███████ ██ ██████ ██ ████ ███████ -*/ -class PeppermintParsedown extends ParsedownExtra -{ - private $internalLinkBase = "./%s"; - - protected $maxParamDepth = 0; - protected $paramStack = []; - - function __construct() - { - // Prioritise our internal link parsing over the regular link parsing - array_unshift($this->InlineTypes["["], "InternalLink"); - // Prioritise our image parser over the regular image parser - array_unshift($this->InlineTypes["!"], "ExtendedImage"); - - $this->inlineMarkerList .= "{"; - if(!isset($this->InlineTypes["{"]) or !is_array($this->InlineTypes["{"])) - $this->InlineTypes["{"] = []; - $this->InlineTypes["{"][] = "Template"; - } - - /* - * ████████ ███████ ███ ███ ██████ ██ █████ ████████ ██ ███ ██ ██████ - * ██ ██ ████ ████ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ - * ██ █████ ██ ████ ██ ██████ ██ ███████ ██ ██ ██ ██ ██ ██ ███ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ███████ ██ ██ ██ ███████ ██ ██ ██ ██ ██ ████ ██████ - */ - protected function inlineTemplate($fragment) - { - global $env, $pageindex; - - // Variable parsing - if(preg_match("/\{\{\{([^}]+)\}\}\}/", $fragment["text"], $matches)) - { - $params = []; - if(!empty($this->paramStack)) - { - $stackEntry = array_slice($this->paramStack, -1)[0]; - $params = !empty($stackEntry) ? $stackEntry["params"] : false; - } - - $variableKey = trim($matches[1]); - - $variableValue = false; - switch ($variableKey) - { - case "@": // Lists all variables and their values - if(!empty($params)) - { - $variableValue = " - \n"; - foreach($params as $key => $value) - { - $variableValue .= "\t\n"; - } - $variableValue .= "
    KeyValue
    " . $this->escapeText($key) . "" . $this->escapeText($value) . "
    "; - } - break; - case "#": // Shows a stack trace - $variableValue = "
      \n"; - $variableValue .= "\t
    1. $env->page
    2. \n"; - foreach($this->paramStack as $curStackEntry) - { - $variableValue .= "\t
    3. " . $curStackEntry["pagename"] . "
    4. \n"; - } - $variableValue .= "
    \n"; - break; - case "~": // Show requested page's name - if(!empty($this->paramStack)) - $variableValue = $this->escapeText($env->page); - break; - case "*": // Lists subpages - $subpages = get_subpages($pageindex, $env->page); - $variableValue = []; - foreach($subpages as $pagename => $depth) - { - $variableValue[] = $pagename; - } - $variableValue = implode(", ", $variableValue); - if(strlen($variableValue) === 0) - $variableValue = "(none yet!)"; - break; - case "+": // Shows a file gallery for subpages with files - // If the upload module isn't present, then there's no point - // in checking for uploaded files - if(!module_exists("feature-upload")) - break; - - $variableValue = []; - $subpages = get_subpages($pageindex, $env->page); - foreach($subpages as $pagename => $depth) - { - // Make sure that this is an uploaded file - if(!$pageindex->$pagename->uploadedfile) - continue; - - $mime_type = $pageindex->$pagename->uploadedfilemime; - - $previewSize = 300; - $previewUrl = "?action=preview&size=$previewSize&page=" . rawurlencode($pagename); - - $previewHtml = ""; - switch(substr($mime_type, 0, strpos($mime_type, "/"))) - { - case "video": - $previewHtml .= "\n"; - break; - case "audio": - $previewHtml .= "\n"; - break; - case "application": - case "image": - default: - $previewHtml .= "\n"; - break; - } - $previewHtml = "$previewHtml$pagename"; - - $variableValue[$pagename] = "
  • $previewHtml
  • "; - } - - if(count($variableValue) === 0) - $variableValue["default"] = "
  • (No files found)
  • \n"; - $variableValue = implode("\n", $variableValue); - $variableValue = ""; - break; - } - if(isset($params[$variableKey])) - { - $variableValue = $params[$variableKey]; - $variableValue = $this->escapeText($variableValue); - } - - if($variableValue !== false) - { - return [ - "extent" => strlen($matches[0]), - "markup" => $variableValue - ]; - } - } - else if(preg_match("/\{\{([^}]+)\}\}/", $fragment["text"], $matches)) - { - $templateElement = $this->templateHandler($matches[1]); - - if(!empty($templateElement)) - { - return [ - "extent" => strlen($matches[0]), - "element" => $templateElement - ]; - } - } - } - - protected function templateHandler($source) - { - global $pageindex, $env; - - - $parts = preg_split("/\\||¦/", trim($source, "{}")); - $parts = array_map("trim", $parts); - - // Extract the name of the template page - $templatePagename = array_shift($parts); - // If the page that we are supposed to use as the tempalte doesn't - // exist, then there's no point in continuing. - if(empty($pageindex->$templatePagename)) - return false; - - // Parse the parameters - $this->maxParamDepth++; - $params = []; - $i = 0; - foreach($parts as $part) - { - if(strpos($part, "=") !== false) - { - // This param contains an equals sign, so it's a named parameter - $keyValuePair = explode("=", $part, 2); - $keyValuePair = array_map("trim", $keyValuePair); - $params[$keyValuePair[0]] = $keyValuePair[1]; - } - else - { - // This isn't a named parameter - $params["$i"] = trim($part); - - $i++; - } - } - // Add the parsed parameters to the parameter stack - $this->paramStack[] = [ - "pagename" => $templatePagename, - "params" => $params - ]; - - $templateFilePath = $env->storage_prefix . $pageindex->$templatePagename->filename; - - $parsedTemplateSource = $this->text(file_get_contents($templateFilePath)); - - // Remove the parsed parameters from the stack - array_pop($this->paramStack); - - return [ - "name" => "div", - "text" => $parsedTemplateSource, - "attributes" => [ - "class" => "template" - ] - ]; - } - - /* - * ██ ███ ██ ████████ ███████ ██████ ███ ██ █████ ██ - * ██ ████ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ █████ ██████ ██ ██ ██ ███████ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ████ ██ ███████ ██ ██ ██ ████ ██ ██ ███████ - * - * ██ ██ ███ ██ ██ ██ ███████ - * ██ ██ ████ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ █████ ███████ - * ██ ██ ██ ██ ██ ██ ██ ██ - * ███████ ██ ██ ████ ██ ██ ███████ - */ - protected function inlineInternalLink($fragment) - { - global $pageindex, $env; - - if(preg_match('/^\[\[([^\]]*)\]\]([^\s!?",.()\[\]{}*=+\/]*)/u', $fragment["text"], $matches)) - { - $linkPage = trim($matches[1]); - $display = $linkPage . trim($matches[2]); - if(strpos($matches[1], "|") !== false || strpos($matches[1], "¦") !== false) - { - // We have a bar character - $parts = preg_split("/\\||¦/", $matches[1], 2); - $linkPage = trim($parts[0]); // The page to link to - $display = trim($parts[1]); // The text to display - } - - $hashCode = ""; - if(strpos($linkPage, "#") !== false) - { - // We want to link to a subsection of a page - $hashCode = substr($linkPage, strpos($linkPage, "#") + 1); - $linkPage = substr($linkPage, 0, strpos($linkPage, "#")); - - // If $linkPage is empty then we want to link to the current page - if(strlen($linkPage) === 0) - $linkPage = $env->page; - } - - // If the page doesn't exist, check varying different - // capitalisations to see if it exists under some variant. - if(empty($pageindex->$linkPage)) - { - if(!empty($pageindex->{ucfirst($linkPage)})) - $linkPage = ucfirst($linkPage); - else if(!empty($pageindex->{ucwords($linkPage)})) - $linkPage = ucwords($linkPage); - } - - - // Construct the full url - $linkUrl = str_replace( - "%s", rawurlencode($linkPage), - $this->internalLinkBase - ); - - if(strlen($hashCode) > 0) - $linkUrl .= "#$hashCode"; - - $result = [ - "extent" => strlen($matches[0]), - "element" => [ - "name" => "a", - "text" => $display, - "attributes" => [ - "href" => $linkUrl - ] - ] - ]; - - if(empty($pageindex->{makepathsafe($linkPage)})) - $result["element"]["attributes"]["class"] = "redlink"; - - return $result; - } - return; - } - - /* - * ███████ ██ ██ ████████ ███████ ███ ██ ██████ ███████ ██████ - * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ - * █████ ███ ██ █████ ██ ██ ██ ██ ██ █████ ██ ██ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ███████ ██ ██ ██ ███████ ██ ████ ██████ ███████ ██████ - * - * ██ ███ ███ █████ ██████ ███████ ███████ - * ██ ████ ████ ██ ██ ██ ██ ██ - * ██ ██ ████ ██ ███████ ██ ███ █████ ███████ - * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ - * ██ ██ ██ ██ ██ ██████ ███████ ███████ - */ - protected function inlineExtendedImage($fragment) - { - global $pageindex; - - if(preg_match('/^!\[(.*)\]\(([^|¦)]+)\s*(?:(?:\||¦)([^|¦)]*))?(?:(?:\||¦)([^|¦)]*))?(?:(?:\||¦)([^)]*))?\)/', $fragment["text"], $matches)) - { - /* - * 0 - Everything - * 1 - Alt text - * 2 - Url - * 3 - First param (optional) - * 4 - Second param (optional) - * 5 - Third param (optional) - */ - $altText = $matches[1]; - $imageUrl = trim(str_replace("&", "&", $matches[2])); // Decode & to allow it in preview urls - $param1 = empty($matches[3]) ? false : strtolower(trim($matches[3])); - $param2 = empty($matches[4]) ? false : strtolower(trim($matches[4])); - $param3 = empty($matches[5]) ? false : strtolower(trim($matches[5])); - $floatDirection = false; - $imageSize = false; - $imageCaption = false; - $shortImageUrl = false; - - if($this->isFloatValue($param1)) - { - // Param 1 is a valid css float: ... value - $floatDirection = $param1; - $imageSize = $this->parseSizeSpec($param2); - } - else if($this->isFloatValue($param2)) - { - // Param 2 is a valid css float: ... value - $floatDirection = $param2; - $imageSize = $this->parseSizeSpec($param1); - } - else if($this->isFloatValue($param3)) - { - $floatDirection = $param3; - $imageSize = $this->parseSizeSpec($param1); - } - else if($param1 === false and $param2 === false) - { - // Neither params were specified - $floatDirection = false; - $imageSize = false; - } - else - { - // Neither of them are floats, but at least one is specified - // This must mean that the first param is a size spec like - // 250x128. - $imageSize = $this->parseSizeSpec($param1); - } - - if($param1 !== false && strtolower(trim($param1)) == "caption") - $imageCaption = true; - if($param2 !== false && strtolower(trim($param2)) == "caption") - $imageCaption = true; - if($param3 !== false && strtolower(trim($param3)) == "caption") - $imageCaption = true; - - //echo("Image url: $imageUrl, Pageindex entry: " . var_export(isset($pageindex->$imageUrl), true) . "\n"); - - if(isset($pageindex->$imageUrl) and $pageindex->$imageUrl->uploadedfile) - { - // We have a short url! Expand it. - $shortImageUrl = $imageUrl; - $imageUrl = "index.php?action=preview&size=" . max($imageSize["x"], $imageSize["y"]) ."&page=" . rawurlencode($imageUrl); - } - - $style = ""; - if($imageSize !== false) - $style .= " max-width: " . $imageSize["x"] . "px; max-height: " . $imageSize["y"] . "px;"; - if($floatDirection) - $style .= " float: $floatDirection;"; - - $urlExtension = pathinfo($imageUrl, PATHINFO_EXTENSION); - $urlType = system_extension_mime_type($urlExtension); - $result = []; - switch(substr($urlType, 0, strpos($urlType, "/"))) - { - case "audio": - $result = [ - "extent" => strlen($matches[0]), - "element" => [ - "name" => "audio", - "text" => $altText, - "attributes" => [ - "src" => $imageUrl, - "controls" => "controls", - "preload" => "metadata", - "style" => trim($style) - ] - ] - ]; - break; - case "video": - $result = [ - "extent" => strlen($matches[0]), - "element" => [ - "name" => "video", - "text" => $altText, - "attributes" => [ - "src" => $imageUrl, - "controls" => "controls", - "preload" => "metadata", - "style" => trim($style) - ] - ] - ]; - break; - case "image": - default: - // If we can't work out what it is, then assume it's an image - $result = [ - "extent" => strlen($matches[0]), - "element" => [ - "name" => "img", - "attributes" => [ - "src" => $imageUrl, - "alt" => $altText, - "title" => $altText, - "style" => trim($style) - ] - ] - ]; - break; - } - - // ~ Image linker ~ - - $imageHref = $shortImageUrl !== false ? "?page=" . rawurlencode($shortImageUrl) : $imageUrl; - $result["element"] = [ - "name" => "a", - "attributes" => [ - "href" => $imageHref - ], - "text" => [$result["element"]], - "handler" => "elements" - ]; - - // ~ - - if($imageCaption) - { - $rawStyle = $result["element"]["attributes"]["style"]; - $containerStyle = preg_replace('/^.*float/', "float", $rawStyle); - $mediaStyle = preg_replace('/\s*float.*;/', "", $rawStyle); - $result["element"] = [ - "name" => "figure", - "attributes" => [ - "style" => $containerStyle - ], - "text" => [ - $result["element"], - [ - "name" => "figcaption", - "text" => $altText - ], - ], - "handler" => "elements" - ]; - $result["element"]["text"][0]["attributes"]["style"] = $mediaStyle; - } - return $result; - } - } - - # ~ - # Utility Methods - # ~ - - private function isFloatValue($value) - { - return in_array(strtolower($value), [ "left", "right" ]); - } - - private function parseSizeSpec($text) - { - if(strpos($text, "x") === false) - return false; - $parts = explode("x", $text, 2); - - if(count($parts) != 2) - return false; - - array_map("trim", $parts); - array_map("intval", $parts); - - if(in_array(0, $parts)) - return false; - - return [ - "x" => $parts[0], - "y" => $parts[1] - ]; - } - - protected function escapeText($text) - { - return htmlentities($text, ENT_COMPAT | ENT_HTML5); - } - - /** - * Sets the base url to be used for internal links. '%s' will be replaced - * with a URL encoded version of the page name. - * @param string $url The url to use when parsing internal links. - */ - public function setInternalLinkBase($url) - { - $this->internalLinkBase = $url; - } -} - - - + + +register_module([ + "name" => "Parsedown", + "version" => "0.9.9", + "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; + if($settings->clean_raw_html) + $parser->setMarkupEscaped(true); + else + $parser->setMarkupEscaped(false); + $result = $parser->text($source); + + return $result; + }); + + add_help_section("20-parser-default", "Editor Syntax", + "

    $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!

    +

    Tips

    +
      +
    • Put 2 spaces at the end of a line to add a soft line break. Leave a blank line to add a head line break (i.e. a new paragraph).
    • +
    • You can add an id to a header that you can link to. Put it in curly braces after the heading name like this: # 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]].
    • +
    +

    Extra Syntax

    +

    $settings->sitename's editor also supports some extra custom syntax, some of which is inspired by Mediawiki. + + + + + + + +
    Type thisTo get thisComments
    [[Internal link]]Internal LinkAn internal link.
    [[Display Text|Internal link]]Display TextAn internal link with some display text.
    ![Alt text](http://example.com/path/to/image.png | 256x256 | right)Alt textAn 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)
    Alt text
    Alt text
    An image with a caption that fits inside a 256px x 256px box, preserving aspect ratio. The caption is taken from the alt text.
    ![Alt text](Files/Cheese.png)Alt textAn 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.

    +

    Templating

    +

    $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.

    +
    Special Variables
    +

    $settings->sitename also supports a number of special built-in variables. Their syntax and function are described below:

    + + + + + + + +
    Type thisTo 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.
    "); + } +]); + +/*** Parsedown versions *** + * Parsedown Core: 1.6.0 * + * Parsedown Extra: 0.7.0 * + **************************/ +$env->parsedown_paths = new stdClass(); +$env->parsedown_paths->parsedown = "https://cdn.rawgit.com/erusev/parsedown/3ebbd730b5c2cf5ce78bc1bf64071407fc6674b7/Parsedown.php"; +$env->parsedown_paths->parsedown_extra = "https://cdn.rawgit.com/erusev/parsedown-extra/11a44e076d02ffcc4021713398a60cd73f78b6f5/ParsedownExtra.php"; + +// Download parsedown and parsedown extra if they don't already exist +if(!file_exists("./Parsedown.php") || filesize("./Parsedown.php") === 0) + file_put_contents("./Parsedown.php", fopen($env->parsedown_paths->parsedown, "r")); +if(!file_exists("./ParsedownExtra.php") || filesize("./ParsedownExtra.php") === 0) + file_put_contents("./ParsedownExtra.php", fopen($env->parsedown_paths->parsedown_extra, "r")); + +require_once("./Parsedown.php"); +require_once("./ParsedownExtra.php"); + +/* + * ██████ █████ ██████ ███████ ███████ ██████ ██████ ██ ██ ███ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ + * ██████ ███████ ██████ ███████ █████ ██ ██ ██ ██ ██ █ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ███████ ███████ ██████ ██████ ███ ███ ██ ████ + * + * ███████ ██ ██ ████████ ███████ ███ ██ ███████ ██ ██████ ███ ██ ███████ + * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ████ ██ ██ + * █████ ███ ██ █████ ██ ██ ██ ███████ ██ ██ ██ ██ ██ ██ ███████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ███████ ██ ██ ██ ███████ ██ ████ ███████ ██ ██████ ██ ████ ███████ +*/ +class PeppermintParsedown extends ParsedownExtra +{ + private $internalLinkBase = "./%s"; + + protected $maxParamDepth = 0; + protected $paramStack = []; + + function __construct() + { + // Prioritise our internal link parsing over the regular link parsing + array_unshift($this->InlineTypes["["], "InternalLink"); + // Prioritise our image parser over the regular image parser + array_unshift($this->InlineTypes["!"], "ExtendedImage"); + + $this->inlineMarkerList .= "{"; + if(!isset($this->InlineTypes["{"]) or !is_array($this->InlineTypes["{"])) + $this->InlineTypes["{"] = []; + $this->InlineTypes["{"][] = "Template"; + } + + /* + * ████████ ███████ ███ ███ ██████ ██ █████ ████████ ██ ███ ██ ██████ + * ██ ██ ████ ████ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ + * ██ █████ ██ ████ ██ ██████ ██ ███████ ██ ██ ██ ██ ██ ██ ███ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ███████ ██ ██ ██ ███████ ██ ██ ██ ██ ██ ████ ██████ + */ + protected function inlineTemplate($fragment) + { + global $env, $pageindex; + + // Variable parsing + if(preg_match("/\{\{\{([^}]+)\}\}\}/", $fragment["text"], $matches)) + { + $params = []; + if(!empty($this->paramStack)) + { + $stackEntry = array_slice($this->paramStack, -1)[0]; + $params = !empty($stackEntry) ? $stackEntry["params"] : false; + } + + $variableKey = trim($matches[1]); + + $variableValue = false; + switch ($variableKey) + { + case "@": // Lists all variables and their values + if(!empty($params)) + { + $variableValue = " + \n"; + foreach($params as $key => $value) + { + $variableValue .= "\t\n"; + } + $variableValue .= "
    KeyValue
    " . $this->escapeText($key) . "" . $this->escapeText($value) . "
    "; + } + break; + case "#": // Shows a stack trace + $variableValue = "
      \n"; + $variableValue .= "\t
    1. $env->page
    2. \n"; + foreach($this->paramStack as $curStackEntry) + { + $variableValue .= "\t
    3. " . $curStackEntry["pagename"] . "
    4. \n"; + } + $variableValue .= "
    \n"; + break; + case "~": // Show requested page's name + if(!empty($this->paramStack)) + $variableValue = $this->escapeText($env->page); + break; + case "*": // Lists subpages + $subpages = get_subpages($pageindex, $env->page); + $variableValue = []; + foreach($subpages as $pagename => $depth) + { + $variableValue[] = $pagename; + } + $variableValue = implode(", ", $variableValue); + if(strlen($variableValue) === 0) + $variableValue = "(none yet!)"; + break; + case "+": // Shows a file gallery for subpages with files + // If the upload module isn't present, then there's no point + // in checking for uploaded files + if(!module_exists("feature-upload")) + break; + + $variableValue = []; + $subpages = get_subpages($pageindex, $env->page); + foreach($subpages as $pagename => $depth) + { + // Make sure that this is an uploaded file + if(!$pageindex->$pagename->uploadedfile) + continue; + + $mime_type = $pageindex->$pagename->uploadedfilemime; + + $previewSize = 300; + $previewUrl = "?action=preview&size=$previewSize&page=" . rawurlencode($pagename); + + $previewHtml = ""; + switch(substr($mime_type, 0, strpos($mime_type, "/"))) + { + case "video": + $previewHtml .= "\n"; + break; + case "audio": + $previewHtml .= "\n"; + break; + case "application": + case "image": + default: + $previewHtml .= "\n"; + break; + } + $previewHtml = "$previewHtml$pagename"; + + $variableValue[$pagename] = "
  • $previewHtml
  • "; + } + + if(count($variableValue) === 0) + $variableValue["default"] = "
  • (No files found)
  • \n"; + $variableValue = implode("\n", $variableValue); + $variableValue = ""; + break; + } + if(isset($params[$variableKey])) + { + $variableValue = $params[$variableKey]; + $variableValue = $this->escapeText($variableValue); + } + + if($variableValue !== false) + { + return [ + "extent" => strlen($matches[0]), + "markup" => $variableValue + ]; + } + } + else if(preg_match("/\{\{([^}]+)\}\}/", $fragment["text"], $matches)) + { + $templateElement = $this->templateHandler($matches[1]); + + if(!empty($templateElement)) + { + return [ + "extent" => strlen($matches[0]), + "element" => $templateElement + ]; + } + } + } + + protected function templateHandler($source) + { + global $pageindex, $env; + + + $parts = preg_split("/\\||¦/", trim($source, "{}")); + $parts = array_map("trim", $parts); + + // Extract the name of the template page + $templatePagename = array_shift($parts); + // If the page that we are supposed to use as the tempalte doesn't + // exist, then there's no point in continuing. + if(empty($pageindex->$templatePagename)) + return false; + + // Parse the parameters + $this->maxParamDepth++; + $params = []; + $i = 0; + foreach($parts as $part) + { + if(strpos($part, "=") !== false) + { + // This param contains an equals sign, so it's a named parameter + $keyValuePair = explode("=", $part, 2); + $keyValuePair = array_map("trim", $keyValuePair); + $params[$keyValuePair[0]] = $keyValuePair[1]; + } + else + { + // This isn't a named parameter + $params["$i"] = trim($part); + + $i++; + } + } + // Add the parsed parameters to the parameter stack + $this->paramStack[] = [ + "pagename" => $templatePagename, + "params" => $params + ]; + + $templateFilePath = $env->storage_prefix . $pageindex->$templatePagename->filename; + + $parsedTemplateSource = $this->text(file_get_contents($templateFilePath)); + + // Remove the parsed parameters from the stack + array_pop($this->paramStack); + + return [ + "name" => "div", + "text" => $parsedTemplateSource, + "attributes" => [ + "class" => "template" + ] + ]; + } + + /* + * ██ ███ ██ ████████ ███████ ██████ ███ ██ █████ ██ + * ██ ████ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ █████ ██████ ██ ██ ██ ███████ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ████ ██ ███████ ██ ██ ██ ████ ██ ██ ███████ + * + * ██ ██ ███ ██ ██ ██ ███████ + * ██ ██ ████ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ █████ ███████ + * ██ ██ ██ ██ ██ ██ ██ ██ + * ███████ ██ ██ ████ ██ ██ ███████ + */ + protected function inlineInternalLink($fragment) + { + global $pageindex, $env; + + if(preg_match('/^\[\[([^\]]*)\]\]([^\s!?",.()\[\]{}*=+\/]*)/u', $fragment["text"], $matches)) + { + $linkPage = trim($matches[1]); + $display = $linkPage . trim($matches[2]); + if(strpos($matches[1], "|") !== false || strpos($matches[1], "¦") !== false) + { + // We have a bar character + $parts = preg_split("/\\||¦/", $matches[1], 2); + $linkPage = trim($parts[0]); // The page to link to + $display = trim($parts[1]); // The text to display + } + + $hashCode = ""; + if(strpos($linkPage, "#") !== false) + { + // We want to link to a subsection of a page + $hashCode = substr($linkPage, strpos($linkPage, "#") + 1); + $linkPage = substr($linkPage, 0, strpos($linkPage, "#")); + + // If $linkPage is empty then we want to link to the current page + if(strlen($linkPage) === 0) + $linkPage = $env->page; + } + + // If the page doesn't exist, check varying different + // capitalisations to see if it exists under some variant. + if(empty($pageindex->$linkPage)) + { + if(!empty($pageindex->{ucfirst($linkPage)})) + $linkPage = ucfirst($linkPage); + else if(!empty($pageindex->{ucwords($linkPage)})) + $linkPage = ucwords($linkPage); + } + + + // Construct the full url + $linkUrl = str_replace( + "%s", rawurlencode($linkPage), + $this->internalLinkBase + ); + + if(strlen($hashCode) > 0) + $linkUrl .= "#$hashCode"; + + $result = [ + "extent" => strlen($matches[0]), + "element" => [ + "name" => "a", + "text" => $display, + "attributes" => [ + "href" => $linkUrl + ] + ] + ]; + + if(empty($pageindex->{makepathsafe($linkPage)})) + $result["element"]["attributes"]["class"] = "redlink"; + + return $result; + } + return; + } + + /* + * ███████ ██ ██ ████████ ███████ ███ ██ ██████ ███████ ██████ + * ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ + * █████ ███ ██ █████ ██ ██ ██ ██ ██ █████ ██ ██ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ███████ ██ ██ ██ ███████ ██ ████ ██████ ███████ ██████ + * + * ██ ███ ███ █████ ██████ ███████ ███████ + * ██ ████ ████ ██ ██ ██ ██ ██ + * ██ ██ ████ ██ ███████ ██ ███ █████ ███████ + * ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + * ██ ██ ██ ██ ██ ██████ ███████ ███████ + */ + protected function inlineExtendedImage($fragment) + { + global $pageindex; + + if(preg_match('/^!\[(.*)\]\(([^|¦)]+)\s*(?:(?:\||¦)([^|¦)]*))?(?:(?:\||¦)([^|¦)]*))?(?:(?:\||¦)([^)]*))?\)/', $fragment["text"], $matches)) + { + /* + * 0 - Everything + * 1 - Alt text + * 2 - Url + * 3 - First param (optional) + * 4 - Second param (optional) + * 5 - Third param (optional) + */ + $altText = $matches[1]; + $imageUrl = trim(str_replace("&", "&", $matches[2])); // Decode & to allow it in preview urls + $param1 = empty($matches[3]) ? false : strtolower(trim($matches[3])); + $param2 = empty($matches[4]) ? false : strtolower(trim($matches[4])); + $param3 = empty($matches[5]) ? false : strtolower(trim($matches[5])); + $floatDirection = false; + $imageSize = false; + $imageCaption = false; + $shortImageUrl = false; + + if($this->isFloatValue($param1)) + { + // Param 1 is a valid css float: ... value + $floatDirection = $param1; + $imageSize = $this->parseSizeSpec($param2); + } + else if($this->isFloatValue($param2)) + { + // Param 2 is a valid css float: ... value + $floatDirection = $param2; + $imageSize = $this->parseSizeSpec($param1); + } + else if($this->isFloatValue($param3)) + { + $floatDirection = $param3; + $imageSize = $this->parseSizeSpec($param1); + } + else if($param1 === false and $param2 === false) + { + // Neither params were specified + $floatDirection = false; + $imageSize = false; + } + else + { + // Neither of them are floats, but at least one is specified + // This must mean that the first param is a size spec like + // 250x128. + $imageSize = $this->parseSizeSpec($param1); + } + + if($param1 !== false && strtolower(trim($param1)) == "caption") + $imageCaption = true; + if($param2 !== false && strtolower(trim($param2)) == "caption") + $imageCaption = true; + if($param3 !== false && strtolower(trim($param3)) == "caption") + $imageCaption = true; + + //echo("Image url: $imageUrl, Pageindex entry: " . var_export(isset($pageindex->$imageUrl), true) . "\n"); + + if(isset($pageindex->$imageUrl) and $pageindex->$imageUrl->uploadedfile) + { + // We have a short url! Expand it. + $shortImageUrl = $imageUrl; + $imageUrl = "index.php?action=preview&size=" . max($imageSize["x"], $imageSize["y"]) ."&page=" . rawurlencode($imageUrl); + } + + $style = ""; + if($imageSize !== false) + $style .= " max-width: " . $imageSize["x"] . "px; max-height: " . $imageSize["y"] . "px;"; + if($floatDirection) + $style .= " float: $floatDirection;"; + + $urlExtension = pathinfo($imageUrl, PATHINFO_EXTENSION); + $urlType = system_extension_mime_type($urlExtension); + $result = []; + switch(substr($urlType, 0, strpos($urlType, "/"))) + { + case "audio": + $result = [ + "extent" => strlen($matches[0]), + "element" => [ + "name" => "audio", + "text" => $altText, + "attributes" => [ + "src" => $imageUrl, + "controls" => "controls", + "preload" => "metadata", + "style" => trim($style) + ] + ] + ]; + break; + case "video": + $result = [ + "extent" => strlen($matches[0]), + "element" => [ + "name" => "video", + "text" => $altText, + "attributes" => [ + "src" => $imageUrl, + "controls" => "controls", + "preload" => "metadata", + "style" => trim($style) + ] + ] + ]; + break; + case "image": + default: + // If we can't work out what it is, then assume it's an image + $result = [ + "extent" => strlen($matches[0]), + "element" => [ + "name" => "img", + "attributes" => [ + "src" => $imageUrl, + "alt" => $altText, + "title" => $altText, + "style" => trim($style) + ] + ] + ]; + break; + } + + // ~ Image linker ~ + + $imageHref = $shortImageUrl !== false ? "?page=" . rawurlencode($shortImageUrl) : $imageUrl; + $result["element"] = [ + "name" => "a", + "attributes" => [ + "href" => $imageHref + ], + "text" => [$result["element"]], + "handler" => "elements" + ]; + + // ~ + + if($imageCaption) + { + $rawStyle = $result["element"]["attributes"]["style"]; + $containerStyle = preg_replace('/^.*float/', "float", $rawStyle); + $mediaStyle = preg_replace('/\s*float.*;/', "", $rawStyle); + $result["element"] = [ + "name" => "figure", + "attributes" => [ + "style" => $containerStyle + ], + "text" => [ + $result["element"], + [ + "name" => "figcaption", + "text" => $altText + ], + ], + "handler" => "elements" + ]; + $result["element"]["text"][0]["attributes"]["style"] = $mediaStyle; + } + return $result; + } + } + + # ~ + # Utility Methods + # ~ + + private function isFloatValue($value) + { + return in_array(strtolower($value), [ "left", "right" ]); + } + + private function parseSizeSpec($text) + { + if(strpos($text, "x") === false) + return false; + $parts = explode("x", $text, 2); + + if(count($parts) != 2) + return false; + + array_map("trim", $parts); + array_map("intval", $parts); + + if(in_array(0, $parts)) + return false; + + return [ + "x" => $parts[0], + "y" => $parts[1] + ]; + } + + protected function escapeText($text) + { + return htmlentities($text, ENT_COMPAT | ENT_HTML5); + } + + /** + * Sets the base url to be used for internal links. '%s' will be replaced + * with a URL encoded version of the page name. + * @param string $url The url to use when parsing internal links. + */ + public function setInternalLinkBase($url) + { + $this->internalLinkBase = $url; + } +} + + + // %next_module% // //////////////////////////////////////////////////////////////////