From b406ac3a047738b7ed82aa2fa9af15305f2ac08b Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Tue, 6 Oct 2026 13:59:31 -0500 Subject: [PATCH] Fix validation findings - Move the ACP permission labels into the language file (the one valdeny): listener formats ACL_U_DOCUMENTATION_LANG/_SECTION. - Skip build directories whose names can't become ACL options (charset, 50-char auth_option limit, lang_ sections), and list them in the ACP. - Default the docs build path to store/phpbbmodders_documentation via a new migration; boards with a build at the old default keep it. - Set the language cookie with $user->set_cookie(), use login_box() for guests, inject the language service, guard getimagesize() by size instead of @, read the image page parameter as multibyte. - Cache section and docs-root lookups per request; explicit nullable parameters for PHP 8.4. - Escape build-derived values in templates; public static and & in the notification type; tabs and braces in the JavaScript. - Cap phpBB at 3.3.x (soft-require and is_enableable), match the skeleton license, consistent array syntax. - Regression tests for each fix. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +- acp/main_module.php | 4 + acp/permission_sync.php | 1 - adm/style/acp_documentation.html | 12 +- adm/style/documentation-tooltips.js | 54 +++- composer.json | 3 +- config/services.yml | 4 +- contrib/caddy-docs-build-deny.conf | 18 +- contrib/iis-docs-build-deny.web.config | 5 + contrib/lighttpd-docs-build-deny.conf | 8 +- contrib/nginx-docs-build-deny.conf | 22 +- controller/documentation_controller.php | 92 +++--- controller/documentation_helper.php | 143 +++++++-- docs/securing-docs-build.md | 76 +++-- event/listener.php | 24 +- ext.php | 11 +- language/en/documentation.php | 4 + language/en/info_acp_documentation.php | 1 + language/en/install_documentation.php | 8 +- license.txt | 58 ---- migrations/v10x/m7_store_docs_path.php | 94 ++++++ notification/type/no_docs_build.php | 8 +- styles/all/template/documentation_body.html | 2 +- .../all/template/js/documentation-search.js | 281 +++++++++--------- .../all/template/js/documentation-tooltips.js | 54 +++- .../documentation_controller_test.php | 50 +++- .../controller/documentation_helper_test.php | 57 ++++ tests/controller/documentation_image_test.php | 7 +- tests/event/listener_test.php | 24 +- tests/migrations/m7_store_docs_path_test.php | 105 +++++++ 30 files changed, 876 insertions(+), 372 deletions(-) create mode 100644 migrations/v10x/m7_store_docs_path.php create mode 100644 tests/migrations/m7_store_docs_path_test.php diff --git a/README.md b/README.md index 5ded895..9bb51f3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Shows the board's Hugo documentation site inside phpBB, styled with the active b ## Requirements -- phpBB 3.3.19 or later +- phpBB 3.3.19 or a later 3.3 release - PHP 8.0 or later ## Installation @@ -21,11 +21,15 @@ Shows the board's Hugo documentation site inside phpBB, styled with the active b 1. Copy the extension to `/ext/phpbbmodders/documentation` 2. In the Administration Control Panel, go to **Customise → Manage extensions** 3. Enable the **Documentation** extension -4. Point `proteus_hugo.sh`'s build output at the extension's own `docs-build/` directory: - `./proteus_hugo.sh all /path/to/ext/phpbbmodders/documentation/docs-build` - This is also the default **docs build path** in the settings, so no further ACP configuration is needed unless the build lives elsewhere. +4. Build the documentation into phpBB's `store/phpbbmodders_documentation/` directory: + `./phpbbdocs_hugo.sh all /path/to/phpbb/store/phpbbmodders_documentation` + This is the default **docs build path** in the settings, so no further ACP configuration is needed unless the build lives elsewhere. Keeping it outside the extension's own directory means updating the extension doesn't delete it. 5. Documentation is open to everyone, including guests, by default. Restrict it afterwards through the normal Permissions screens if needed. -6. **Block direct web access to `docs-build/`** on anything other than Apache or LiteSpeed. Without it, the raw pages can be read directly, bypassing the extension's permissions. See [docs/securing-docs-build.md](docs/securing-docs-build.md) for Nginx, Caddy, IIS and lighttpd snippets. +6. **Make sure the build can't be read directly from the web.** phpBB's own deny rules for `store/` cover the default location on Apache, LiteSpeed, and servers set up from phpBB's sample configs. Without a deny rule, the raw pages can be read directly, bypassing the extension's permissions. See [docs/securing-docs-build.md](docs/securing-docs-build.md). + +The build's HTML is shown inside the board as-is, so only put a build you trust at the docs build path, in a directory only administrators can write to. + +Boards upgraded from an earlier version keep their build at `ext/phpbbmodders/documentation/docs-build/` if one was already there; move it to the new location and update the docs build path to stop the next extension update deleting it. If you add a new top-level section or a new language to the docs build later, use the **Resync permissions** button on the settings page. @@ -63,8 +67,8 @@ checks for every file. Index files named by content hash may stay in the browser's private cache for the ACP's **Search index cache time** (default 60 minutes, 0 turns it off), so a user who loses access to a section can still search what they already downloaded until it expires. Separate navigation limits search to the current -documentation side. Keep raw access to `docs-build/` blocked, including its -Pagefind bundles, as described in [docs/securing-docs-build.md](docs/securing-docs-build.md). +documentation side. Keep raw access to the build directory blocked, including +its Pagefind bundles, as described in [docs/securing-docs-build.md](docs/securing-docs-build.md). `styles/all/template/js/documentation-search.js` is a copy of phpbbdocs-hugo's `site/static/js/documentation-search.js`; keep the two in sync. diff --git a/acp/main_module.php b/acp/main_module.php index 3a9b839..88b4c6f 100644 --- a/acp/main_module.php +++ b/acp/main_module.php @@ -188,6 +188,10 @@ public function main($id, $mode) 'SECTIONS' => implode(', ', $doc_helper->get_available_sections($lang_code)), )); } + + // Only complete now that every language's sections have been read. + $ignored = $doc_helper->get_ignored_build_names(); + $template->assign_var('DOCUMENTATION_IGNORED_NAMES', empty($ignored) ? '' : $language->lang('ACP_DOCUMENTATION_IGNORED_NAMES', implode(', ', $ignored))); } } } diff --git a/acp/permission_sync.php b/acp/permission_sync.php index 5a27a61..448e4d9 100644 --- a/acp/permission_sync.php +++ b/acp/permission_sync.php @@ -46,7 +46,6 @@ class permission_sync const DEFAULT_GRANTED_ROLES = array('ROLE_USER_FULL', 'ROLE_USER_STANDARD'); const DEFAULT_GRANTED_GROUPS = array('GUESTS', 'BOTS'); - /** @var driver_interface */ protected $db; diff --git a/adm/style/acp_documentation.html b/adm/style/acp_documentation.html index d834e7c..d6d513a 100644 --- a/adm/style/acp_documentation.html +++ b/adm/style/acp_documentation.html @@ -23,7 +23,7 @@

{{ lang('WARNING') }}

{% if RESYNC_FEEDBACK %}
-

{{ RESYNC_FEEDBACK }}

+

{{ RESYNC_FEEDBACK|e }}

{% endif %} @@ -113,6 +113,12 @@

{{ lang('WARNING') }}

{% if S_DOCUMENTATION_BUILD_FOUND %}

{{ lang('ACP_DOCUMENTATION_DETECTED') }}

+{% if DOCUMENTATION_IGNORED_NAMES %} +
+

{{ DOCUMENTATION_IGNORED_NAMES|e }}

+
+{% endif %} + @@ -123,8 +129,8 @@

{{ lang('ACP_DOCUMENTATION_DETECTED') }}

{% for documentation_lang in documentation_lang %} - - + + {% endfor %} diff --git a/adm/style/documentation-tooltips.js b/adm/style/documentation-tooltips.js index d532f3a..4359b43 100644 --- a/adm/style/documentation-tooltips.js +++ b/adm/style/documentation-tooltips.js @@ -1,6 +1,15 @@ (function () { "use strict"; + // A malformed attribute must not stop the remaining tooltips working. + function parseJson(value) { + try { + return JSON.parse(value || "{}") || {}; + } catch (error) { + return {}; + } + } + function annotate(node, explanation) { var term = document.createElement("span"); term.className = "documentation-tooltip-term"; @@ -11,8 +20,8 @@ } document.querySelectorAll("[data-doc-abbreviations]").forEach(function (article) { - var definitions = JSON.parse(article.dataset.docAbbreviations); - var headings = JSON.parse(article.dataset.docEventHeadings || "{}"); + var definitions = parseJson(article.dataset.docAbbreviations); + var headings = parseJson(article.dataset.docEventHeadings); var seen = Object.create(null); var walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT); var nodes = []; @@ -27,7 +36,9 @@ var offset = 0; var match; while ((match = pattern.exec(node.textContent))) { - if (seen[match[0]] || !definitions[match[0]]) continue; + if (seen[match[0]] || !definitions[match[0]]) { + continue; + } seen[match[0]] = true; fragment.appendChild(document.createTextNode(node.textContent.slice(offset, match.index))); var text = document.createTextNode(match[0]); @@ -50,10 +61,16 @@ }); article.querySelectorAll("img[alt]").forEach(function (image) { var description = image.getAttribute("alt").trim(); - if (!description) return; + if (!description) { + return; + } var target = image.closest("a") || image; - if (!target.dataset.docTooltip) target.dataset.docTooltip = description; - if (target === image) target.tabIndex = 0; + if (!target.dataset.docTooltip) { + target.dataset.docTooltip = description; + } + if (target === image) { + target.tabIndex = 0; + } target.classList.add("documentation-tooltip-image"); }); }); @@ -73,8 +90,11 @@ var ids = (active.getAttribute("aria-describedby") || "").split(/\s+/).filter(function (id) { return id && id !== popup.id; }); - if (ids.length) active.setAttribute("aria-describedby", ids.join(" ")); - else active.removeAttribute("aria-describedby"); + if (ids.length) { + active.setAttribute("aria-describedby", ids.join(" ")); + } else { + active.removeAttribute("aria-describedby"); + } } active = null; popup.hidden = true; @@ -90,14 +110,18 @@ var rect = target.getBoundingClientRect(); var left = Math.max(12, Math.min(rect.left, window.innerWidth - popup.offsetWidth - 12)); var top = rect.bottom + 6; - if (top + popup.offsetHeight > window.innerHeight - 12) top = Math.max(12, rect.top - popup.offsetHeight - 6); + if (top + popup.offsetHeight > window.innerHeight - 12) { + top = Math.max(12, rect.top - popup.offsetHeight - 6); + } popup.style.left = left + "px"; popup.style.top = top + "px"; } function scheduleHide() { timer = setTimeout(function () { - if (active !== document.activeElement) hide(); + if (active !== document.activeElement) { + hide(); + } }, 150); } @@ -110,9 +134,15 @@ }); popup.addEventListener("mouseenter", function () { clearTimeout(timer); }); popup.addEventListener("mouseleave", scheduleHide); - document.addEventListener("keydown", function (event) { if (event.key === "Escape") hide(); }); + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + hide(); + } + }); document.addEventListener("click", function (event) { - if (!event.target.closest("[data-doc-tooltip], .documentation-tooltip-popup")) hide(); + if (!event.target.closest("[data-doc-tooltip], .documentation-tooltip-popup")) { + hide(); + } }); window.addEventListener("resize", hide); document.addEventListener("scroll", hide, true); diff --git a/composer.json b/composer.json index 9234466..4614cbf 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,6 @@ ], "require": { "php": ">=8.0.0", - "phpbb/phpbb": ">=3.3.19", "composer/installers": "^1.0 || ^2.0" }, "require-dev": { @@ -31,7 +30,7 @@ "extra": { "display-name": "Documentation", "soft-require": { - "phpbb/phpbb": ">=3.3.19" + "phpbb/phpbb": ">=3.3.19,<4.0.0@dev" } } } diff --git a/config/services.yml b/config/services.yml index 14d8f4a..ff960a6 100644 --- a/config/services.yml +++ b/config/services.yml @@ -18,9 +18,8 @@ services: - '@template' - '@user' - '@phpbbmodders.documentation.helper' - - '%core.root_path%' - - '%core.php_ext%' - '@request' + - '@language' phpbbmodders.documentation.listener: class: phpbbmodders\documentation\event\listener @@ -31,6 +30,7 @@ services: - '@phpbbmodders.documentation.helper' - '@template' - '@phpbbmodders.documentation.permission_sync' + - '@language' tags: - { name: event.listener } diff --git a/contrib/caddy-docs-build-deny.conf b/contrib/caddy-docs-build-deny.conf index 159cc82..02dc3c2 100644 --- a/contrib/caddy-docs-build-deny.conf +++ b/contrib/caddy-docs-build-deny.conf @@ -5,13 +5,15 @@ # run when a request goes through phpBB's own /documentation/... route, # not a direct hit on this directory. # -# Add these two lines inside your existing site block (the one serving -# phpBB), above any php_fastcgi/file_server directives. Caddy's default -# directive ordering runs `respond` before those regardless of where in -# the block it's written, so this reliably wins even placed at the top. -# If phpBB isn't installed at your site root, adjust the path below to -# match (e.g. under a /forum/ path, this becomes -# /forum/ext/phpbbmodders/documentation/docs-build/*). +# phpBB ships no Caddy sample config, so nothing denies store/ by default. +# The matcher covers the default build path (store/phpbbmodders_documentation/) +# and the old default inside the extension. Add these two lines inside your +# existing site block (the one serving phpBB), above any +# php_fastcgi/file_server directives. Caddy's default directive ordering +# runs `respond` before those regardless of where in the block it's +# written, so this reliably wins even placed at the top. +# If phpBB isn't installed at your site root, adjust the paths below to +# match (e.g. under a /forum/ path, /store/... becomes /forum/store/...). -@phpbbmodders_documentation_docs_build path /ext/phpbbmodders/documentation/docs-build/* +@phpbbmodders_documentation_docs_build path /store/phpbbmodders_documentation/* /ext/phpbbmodders/documentation/docs-build/* respond @phpbbmodders_documentation_docs_build 403 diff --git a/contrib/iis-docs-build-deny.web.config b/contrib/iis-docs-build-deny.web.config index 5af9458..02d9950 100644 --- a/contrib/iis-docs-build-deny.web.config +++ b/contrib/iis-docs-build-deny.web.config @@ -6,6 +6,11 @@ at their real filesystem path, bypassing the extension's per-section/per-language ACL checks entirely. + Not needed for the default build path (store/phpbbmodders_documentation/) + when phpBB's own root web.config is in use, since it already hides the + "store" segment. This block covers the old default inside the + extension; for any other location, hide its segment the same way. + Merge this block into your site's existing web.config (inside the top-level element, alongside phpBB's own and blocks — diff --git a/contrib/lighttpd-docs-build-deny.conf b/contrib/lighttpd-docs-build-deny.conf index aabef83..33eb258 100644 --- a/contrib/lighttpd-docs-build-deny.conf +++ b/contrib/lighttpd-docs-build-deny.conf @@ -6,11 +6,15 @@ # checks entirely — those only run when a request goes through phpBB's # own /documentation/... route, not a direct hit on this directory. # +# Not needed for the default build path (store/phpbbmodders_documentation/) +# if your config includes the store rule from phpBB's sample. This covers +# both that path and the old default inside the extension. +# # Add this inside the same $HTTP["host"] block as phpBB's own "Deny # access to internal phpbb files" rule (see docs/lighttpd.sample.conf in # the phpBB package). If phpBB isn't installed at your web root, adjust -# the path below to match. +# the paths below to match. -$HTTP["url"] =~ "^/ext/phpbbmodders/documentation/docs-build/" { +$HTTP["url"] =~ "^/(store/phpbbmodders_documentation|ext/phpbbmodders/documentation/docs-build)/" { url.access-deny = ( "" ) } diff --git a/contrib/nginx-docs-build-deny.conf b/contrib/nginx-docs-build-deny.conf index 0f292b6..c7204dc 100644 --- a/contrib/nginx-docs-build-deny.conf +++ b/contrib/nginx-docs-build-deny.conf @@ -6,20 +6,28 @@ # only run when a request goes through phpBB's own /documentation/... # route, not a direct hit on this directory. # -# Nginx doesn't read .htaccess (unlike Apache, where docs-build/.htaccess -# already covers this), so this needs to be added explicitly to your -# site's server block. Place it alongside phpBB's own "Deny access to +# Not needed for the default build path (store/phpbbmodders_documentation/) +# if your server block includes the store rule from phpBB's +# nginx.sample.conf. These blocks cover both that path and the old default +# inside the extension. +# +# Nginx doesn't read .htaccess, so this needs to be added explicitly to +# your site's server block. Place it alongside phpBB's own "Deny access to # internal phpbb files" location block, inside the same `server { ... }` # — see docs/nginx.sample.conf in the phpBB package for that block's # exact location if you're not sure where. # -# If phpBB isn't installed at your web root, adjust the path below to -# match (e.g. if phpBB lives at /forum/, this becomes -# /forum/ext/phpbbmodders/documentation/docs-build/). +# If phpBB isn't installed at your web root, adjust the paths below to +# match (e.g. if phpBB lives at /forum/, /store/... becomes /forum/store/...). -location ^~ /ext/phpbbmodders/documentation/docs-build/ { +location ^~ /store/phpbbmodders_documentation/ { deny all; # deny was ignored before nginx 0.8.40 for connections over IPv6. # Use internal directive to prohibit access on older versions. internal; } + +location ^~ /ext/phpbbmodders/documentation/docs-build/ { + deny all; + internal; +} diff --git a/controller/documentation_controller.php b/controller/documentation_controller.php index b33eaf2..9348f8a 100644 --- a/controller/documentation_controller.php +++ b/controller/documentation_controller.php @@ -14,6 +14,7 @@ use phpbb\config\config; use phpbb\controller\helper as controller_helper; use phpbb\exception\http_exception; +use phpbb\language\language; use phpbb\request\request_interface; use phpbb\template\template; use phpbb\user; @@ -25,6 +26,15 @@ class documentation_controller /** Stands in for a page path in the page route given to the search script. */ const SEARCH_PATH_PLACEHOLDER = 'documentation-search-path'; + /** Image types the image route serves. SVG is left out on purpose. */ + const IMAGE_MIME_TYPES = array('image/png', 'image/gif', 'image/jpeg', 'image/webp', 'image/avif', 'image/bmp', 'image/x-icon'); + + /** + * getimagesize() raises a notice for files shorter than this, and no + * real image is this small, so smaller files are rejected first. + */ + const MIN_IMAGE_BYTES = 12; + /** @var auth */ protected $auth; @@ -43,16 +53,13 @@ class documentation_controller /** @var documentation_helper */ protected $doc_helper; - /** @var string */ - protected $phpbb_root_path; - - /** @var string */ - protected $php_ext; - /** @var request_interface */ protected $request; - public function __construct(auth $auth, config $config, controller_helper $controller_helper, template $template, user $user, documentation_helper $doc_helper, $phpbb_root_path, $php_ext, request_interface $request) + /** @var language */ + protected $language; + + public function __construct(auth $auth, config $config, controller_helper $controller_helper, template $template, user $user, documentation_helper $doc_helper, request_interface $request, language $language) { $this->auth = $auth; $this->config = $config; @@ -60,9 +67,8 @@ public function __construct(auth $auth, config $config, controller_helper $contr $this->template = $template; $this->user = $user; $this->doc_helper = $doc_helper; - $this->phpbb_root_path = $phpbb_root_path; - $this->php_ext = $php_ext; $this->request = $request; + $this->language = $language; } /** @@ -105,14 +111,14 @@ public function handle($lang, $path) if (!$this->auth->acl_get('u_phpbbmodders_documentation_lang_' . $lang)) { - return $this->access_denied_response(); + $this->deny_access(); } $allowed_sections = $this->allowed_sections($lang, $section); if ($section === '' ? empty($allowed_sections) : !in_array($section, $allowed_sections, true)) { - return $this->access_denied_response(); + $this->deny_access(); } $content_lang = $lang; @@ -125,12 +131,12 @@ public function handle($lang, $path) } if (!$this->auth->acl_get('u_phpbbmodders_documentation_lang_' . $content_lang)) { - return $this->access_denied_response(); + $this->deny_access(); } $allowed_sections = $this->allowed_sections($content_lang, $section); if ($section === '' ? empty($allowed_sections) : !in_array($section, $allowed_sections, true)) { - return $this->access_denied_response(); + $this->deny_access(); } } @@ -177,7 +183,7 @@ public function search($lang) $sections = $this->allowed_sections($lang, $scope); if (!$this->auth->acl_get('u_phpbbmodders_documentation_lang_' . $lang) || empty($sections)) { - return $this->access_denied_response(); + $this->deny_access(); } $bundles = array(); foreach ($this->doc_helper->get_search_bundle_sections($lang, $sections) as $section) @@ -205,13 +211,13 @@ public function search($lang) 'placeholder' => self::SEARCH_PATH_PLACEHOLDER, )), 'DOCUMENTATION_SEARCH_MESSAGES' => json_encode(array( - 'length' => $this->user->lang('DOCUMENTATION_SEARCH_LENGTH'), - 'noResults' => $this->user->lang('DOCUMENTATION_SEARCH_NO_RESULTS'), - 'unavailable' => $this->user->lang('DOCUMENTATION_SEARCH_UNAVAILABLE'), - 'loading' => $this->user->lang('DOCUMENTATION_SEARCH_LOADING'), + 'length' => $this->language->lang('DOCUMENTATION_SEARCH_LENGTH'), + 'noResults' => $this->language->lang('DOCUMENTATION_SEARCH_NO_RESULTS'), + 'unavailable' => $this->language->lang('DOCUMENTATION_SEARCH_UNAVAILABLE'), + 'loading' => $this->language->lang('DOCUMENTATION_SEARCH_LOADING'), )), )); - $response = $this->controller_helper->render('documentation_search.html', $this->user->lang('DOCUMENTATION_SEARCH')); + $response = $this->controller_helper->render('documentation_search.html', $this->language->lang('DOCUMENTATION_SEARCH')); $response->headers->set('Cache-Control', 'private, no-store'); return $response; } @@ -258,6 +264,15 @@ public function search_bundle($lang, $section, $path) ), false); } + /** + * Serves an image from the build, but only one the given page's + * article actually references, with the same language and section + * permission checks as that page. + * + * @param string $lang Language directory the image is stored in. + * @param string $path Image path inside that language directory. + * @return BinaryFileResponse + */ public function image($lang, $path) { if (!(bool) $this->config['phpbbmodders_documentation_enabled']) @@ -269,7 +284,7 @@ public function image($lang, $path) throw new http_exception(503, 'DOCUMENTATION_NOT_BUILT'); } - $page = $this->request->variable('page', ''); + $page = $this->request->variable('page', '', true); $page_lang = $this->request->variable('page_lang', (string) $lang); if (!$this->doc_helper->is_known_language($lang) || !$this->doc_helper->is_known_language($page_lang)) { @@ -288,8 +303,8 @@ public function image($lang, $path) } $file = $this->doc_helper->get_referenced_image_file($page_lang, $page, $lang, $path); - $info = $file !== false ? @getimagesize($file) : false; - if ($info === false || !in_array($info['mime'], array('image/png', 'image/gif', 'image/jpeg', 'image/webp', 'image/avif', 'image/bmp', 'image/x-icon'), true)) + $info = ($file !== false && is_readable($file) && filesize($file) >= self::MIN_IMAGE_BYTES) ? getimagesize($file) : false; + if ($info === false || !in_array($info['mime'], self::IMAGE_MIME_TYPES, true)) { throw new http_exception(404, 'DOCUMENTATION_PAGE_NOT_FOUND'); } @@ -302,29 +317,36 @@ public function image($lang, $path) } /** - * A guest lacking the required permission is sent to log in (with a - * return URL back to this page) rather than shown a bare 403 — they - * may well have access once signed in. A signed-in user without - * access gets the 403, matching phpBB's usual pattern for restricted - * areas (login_box() vs. SORRY_AUTH_READ). + * A guest lacking the required permission gets phpBB's login box, + * which returns them to this page — they may well have access once + * signed in. A signed-in user without access gets a 403, matching + * phpBB's usual pattern for restricted areas (login_box() vs. + * SORRY_AUTH_READ). * - * @return \Symfony\Component\HttpFoundation\Response + * @return void Never returns: login_box() ends the request. + * @throws http_exception 403 for a signed-in user. */ - protected function access_denied_response() + protected function deny_access() { if (empty($this->user->data['is_registered'])) { - $login_url = append_sid( - "{$this->phpbb_root_path}ucp.{$this->php_ext}", - 'mode=login&redirect=' . urlencode($this->controller_helper->get_current_url()) - ); - - return new RedirectResponse($login_url, 302); + $this->show_login_box(); } throw new http_exception(403, 'DOCUMENTATION_ACCESS_DENIED'); } + /** + * Wrapper around phpBB's login_box(), which renders the login page and + * ends the request, so tests can replace it. + * + * @return void + */ + protected function show_login_box() + { + login_box('', $this->language->lang('DOCUMENTATION_LOGIN_EXPLAIN')); + } + /** * @param string $lang * @param string $current_section The section of the page actually diff --git a/controller/documentation_helper.php b/controller/documentation_helper.php index 84a7cb7..3398e49 100644 --- a/controller/documentation_helper.php +++ b/controller/documentation_helper.php @@ -22,7 +22,26 @@ */ class documentation_helper { - const COOKIE_NAME = 'phpbb_docs_lang'; + /** Cookie name, prefixed with the board's cookie name by \phpbb\user::set_cookie(). */ + const COOKIE_NAME = 'docs_lang'; + + /** How long the language choice is remembered, in seconds (one year). */ + const COOKIE_LIFETIME = 31536000; + + /** ACL option prefix for section permissions. */ + const PERMISSION_PREFIX = 'u_phpbbmodders_documentation_'; + + /** ACL option prefix for language permissions. */ + const LANG_PERMISSION_PREFIX = 'u_phpbbmodders_documentation_lang_'; + + /** Length of phpBB's acl_options.auth_option column. */ + const AUTH_OPTION_MAX_LENGTH = 50; + + /** Characters a language or section directory name may use. */ + const BUILD_NAME_PATTERN = '/\A[a-z0-9][a-z0-9_-]*\z/'; + + /** Page whose wide tables get wrapped in a horizontally scrollable region. */ + const EVENTS_LIST_PATH = 'development/extensions/events_list'; /** * The one top-level section name the developer docs build produces @@ -85,6 +104,15 @@ class documentation_helper /** @var array|null cached list of language codes present in the build */ protected $available_languages; + /** @var array cached section lists, keyed by language code */ + protected $available_sections = array(); + + /** @var array configured docs path => resolved docs root (or false) */ + protected $docs_roots = array(); + + /** @var array build directory names skipped by is_usable_build_name() */ + protected $ignored_build_names = array(); + public function __construct(config $config, language $language, request_interface $request, user $user, $phpbb_root_path, controller_helper $controller_helper) { $this->config = $config; @@ -98,8 +126,8 @@ public function __construct(config $config, language $language, request_interfac /** * Absolute filesystem path to the Hugo build's public/ directory — * only if it actually contains a build. A configured path that - * exists but is empty (e.g. docs-build/ before proteus_hugo.sh has - * ever run) is treated the same as "not found": every caller of this + * exists but is empty (e.g. before the Hugo build has ever been + * written to it) is treated the same as "not found": every caller of this * method uses it to decide whether documentation is usable at all, * and an existing-but-empty directory isn't. * @@ -107,9 +135,14 @@ public function __construct(config $config, language $language, request_interfac */ public function get_docs_root() { - $root = $this->resolve_configured_root(); + $configured = (string) $this->config['phpbbmodders_documentation_docs_path']; + if (!array_key_exists($configured, $this->docs_roots)) + { + $root = $this->resolve_configured_root(); + $this->docs_roots[$configured] = ($root !== false && $this->has_any_language_directory($root)) ? $root : false; + } - return ($root !== false && $this->has_any_language_directory($root)) ? $root : false; + return $this->docs_roots[$configured]; } /** @@ -176,10 +209,19 @@ public function get_available_languages() continue; } - if (is_file($entry->getPathname() . '/index.html')) + if (!is_file($entry->getPathname() . '/index.html')) + { + continue; + } + + if ($this->is_usable_build_name($entry->getFilename(), self::LANG_PERMISSION_PREFIX)) { $this->available_languages[] = $entry->getFilename(); } + else + { + $this->ignored_build_names[$entry->getFilename()] = true; + } } sort($this->available_languages); @@ -196,38 +238,78 @@ public function get_available_languages() */ public function get_available_sections($lang) { - $sections = array(); - $root = $this->get_docs_root(); if ($root === false || !$this->is_known_language($lang)) { - return $sections; + return array(); } - $lang_dir = $root . '/' . $lang; - if (!is_dir($lang_dir)) + if (isset($this->available_sections[$lang])) { - return $sections; + return $this->available_sections[$lang]; } - foreach (new \DirectoryIterator($lang_dir) as $entry) + $sections = array(); + $lang_dir = $root . '/' . $lang; + if (is_dir($lang_dir)) { - if ($entry->isDot() || !$entry->isDir() || $entry->getFilename() === 'images') + foreach (new \DirectoryIterator($lang_dir) as $entry) { - continue; - } + if ($entry->isDot() || !$entry->isDir() || $entry->getFilename() === 'images' + || !is_file($entry->getPathname() . '/index.html')) + { + continue; + } - if (is_file($entry->getPathname() . '/index.html')) - { - $sections[] = $entry->getFilename(); + // A section named lang_ would share its ACL option with + // the permission for language . + $name = $entry->getFilename(); + if (strpos($name, 'lang_') === 0 || !$this->is_usable_build_name($name, self::PERMISSION_PREFIX)) + { + $this->ignored_build_names[$lang . '/' . $name] = true; + continue; + } + + $sections[] = $name; } } sort($sections); + $this->available_sections[$lang] = $sections; return $sections; } + /** + * Whether a build directory name can become part of an ACL option: + * lowercase letters, digits, "_" and "-" only, and short enough that + * $prefix . $name fits phpBB's auth_option column. + * + * @param string $name + * @param string $prefix + * @return bool + */ + protected function is_usable_build_name($name, $prefix) + { + return preg_match(self::BUILD_NAME_PATTERN, $name) + && strlen($prefix . $name) <= self::AUTH_OPTION_MAX_LENGTH; + } + + /** + * Build directories skipped because their names can't be used as + * permission names. Only complete after get_available_languages() and + * get_available_sections() have run for every language. + * + * @return array Directory names, relative to the docs root. + */ + public function get_ignored_build_names() + { + $names = array_keys($this->ignored_build_names); + sort($names); + + return $names; + } + /** * Human-readable label per language code, read once from the * language-switcher markup Hugo already rendered on the homepage of @@ -329,7 +411,7 @@ public function get_fallback_language() */ public function resolve_default_language() { - $cookie_lang = $this->request->variable(self::COOKIE_NAME, '', true, \phpbb\request\request_interface::COOKIE); + $cookie_lang = $this->request->variable($this->get_cookie_name(), '', true, \phpbb\request\request_interface::COOKIE); if ($this->is_known_language($cookie_lang)) { return $cookie_lang; @@ -539,8 +621,19 @@ public function get_nav_icon($config_key) */ public function set_language_cookie($lang) { - $this->request->overwrite(self::COOKIE_NAME, $lang, \phpbb\request\request_interface::COOKIE); - setcookie(self::COOKIE_NAME, $lang, time() + 60 * 60 * 24 * 365, '/'); + $this->request->overwrite($this->get_cookie_name(), $lang, \phpbb\request\request_interface::COOKIE); + $this->user->set_cookie(self::COOKIE_NAME, $lang, time() + self::COOKIE_LIFETIME); + } + + /** + * Full name of the language cookie, as \phpbb\user::set_cookie() + * writes it: the board's cookie name, "_", then COOKIE_NAME. + * + * @return string + */ + protected function get_cookie_name() + { + return $this->config['cookie_name'] . '_' . self::COOKIE_NAME; } /** @@ -748,7 +841,7 @@ public function get_referenced_image_file($page_lang, $page, $image_lang, $image * @param bool $allow_fallback False when the caller already authorized the selected language. * @return array|false ['lang', 'used_fallback', 'title', 'breadcrumb_html', 'sidebar_html', 'article_html'] */ - public function resolve_and_load($lang, $path, array $allowed_sections = null, $allow_fallback = true) + public function resolve_and_load($lang, $path, ?array $allowed_sections = null, $allow_fallback = true) { $file = $this->get_content_file($lang, $path); $used_fallback = false; @@ -795,7 +888,7 @@ public function resolve_and_load($lang, $path, array $allowed_sections = null, $ * @param array|null $allowed_sections Sections to keep in the sidebar; null skips filtering. * @return array|false ['title', 'breadcrumb_html', 'sidebar_html', 'article_html'] */ - protected function extract_fragments($file, $lang, $path, array $allowed_sections = null) + protected function extract_fragments($file, $lang, $path, ?array $allowed_sections = null) { $dom = new \DOMDocument(); libxml_use_internal_errors(true); @@ -820,7 +913,7 @@ protected function extract_fragments($file, $lang, $path, array $allowed_section return false; } - if (trim($path, '/') === 'development/extensions/events_list') + if (trim($path, '/') === self::EVENTS_LIST_PATH) { foreach (iterator_to_array($article_node->getElementsByTagName('table')) as $table) { diff --git a/docs/securing-docs-build.md b/docs/securing-docs-build.md index a28fc06..8f9c731 100644 --- a/docs/securing-docs-build.md +++ b/docs/securing-docs-build.md @@ -1,47 +1,67 @@ # Securing the docs build directory -`docs-build/` is generated output, not source — it's gitignored (aside -from a `.gitkeep` placeholder) and gets rebuilt from -`proteus_doc_.xml` any time `proteus_hugo.sh` runs. - -**`docs-build/` ships with an Apache `.htaccess` denying all direct web -access**, the same pattern phpBB's own `cache/`, `files/`, `store/`, and -`config/` directories use. This matters beyond tidiness: without it, -anyone who knows or guesses the URL could browse the raw, un-styled Hugo -pages directly and read a section/language your Permissions screens are -restricting — bypassing the extension's ACL checks entirely, since those +The docs build is the generated Hugo site that `phpbbdocs_hugo.sh` writes +from `proteus_doc_.xml`. By default it lives in phpBB's +`store/phpbbmodders_documentation/` directory. + +**The build must not be readable directly from the web.** Anyone who could +browse the raw Hugo pages could read a section or language your Permissions +screens restrict, bypassing the extension's checks entirely, since those only run when a request goes through phpBB's own `/documentation/...` -route. This has no effect on the extension's own PHP code, which reads -these files directly off disk regardless of any of the below — a deny -rule only blocks browser/HTTP access to the directory, never phpBB's own -server-side reads. +routes. A deny rule has no effect on the extension itself: its PHP code +reads the files straight off disk. Article images are served through phpBB's `/documentation-image/{lang}/{path}` -route, not through direct access to `docs-build/`. The route checks the -source page's language and section permissions, the image language's -permission, and that the article references the requested image. Only -supported raster images are served; SVG files are not served by this route. -Keep the directory's deny rule in place when enabling images. +route, not through direct access to the build. The route checks the source +page's language and section permissions, the image language's permission, +and that the article references the requested image. Only supported raster +images are served; SVG files are not served by this route. Search bundles are served the same way, through `/documentation-search-bundle/{lang}/{section}/{path}`. Each section has its own Pagefind bundle, and the route serves a file only when the user may read that language and section. Content-hashed index files may be cached privately by the browser for the ACP's **Search index cache time** (default 60 -minutes; 0 turns it off). Direct access to `docs-build/` would expose every +minutes; 0 turns it off). Direct access to the build would expose every section's bundle, so the deny rule matters for search too. -The `.htaccess` only helps on **Apache with `AllowOverride` enabled for -this path**, and on **LiteSpeed/OpenLiteSpeed**, which reads `.htaccess` -natively for Apache compatibility — nothing further needed on either. -Everything else needs an equivalent deny rule added at the server-config -level, since none of them read `.htaccess`: +## Trusting the build + +The extension shows the build's article, sidebar and breadcrumb HTML inside +the board as-is, scripts included. Treat the docs build path like code: only +put a build there that you produced yourself, and keep the directory +writable only by administrators and the build process. + +## The default location: `store/` + +phpBB already denies web access to `store/`: + +- **Apache** and **LiteSpeed/OpenLiteSpeed**: phpBB's own `store/.htaccess` + (needs `AllowOverride` on Apache). +- **Nginx**, **lighttpd** and **IIS**: the `store` rule in phpBB's + `docs/nginx.sample.conf`, `docs/lighttpd.sample.conf` and root + `web.config`. Check your server config includes it if it wasn't built from + those samples. +- **Caddy**: phpBB ships no Caddy sample, so add the snippet below. + +## Other locations + +A build kept somewhere else under the web root needs its own deny rule. +The snippets in `contrib/` cover `store/phpbbmodders_documentation/` and the +old default, `ext/phpbbmodders/documentation/docs-build/`; adjust the path +for any other location: - **Nginx**: [`contrib/nginx-docs-build-deny.conf`](../contrib/nginx-docs-build-deny.conf) - **Caddy**: [`contrib/caddy-docs-build-deny.conf`](../contrib/caddy-docs-build-deny.conf) - **IIS**: [`contrib/iis-docs-build-deny.web.config`](../contrib/iis-docs-build-deny.web.config) - **lighttpd**: [`contrib/lighttpd-docs-build-deny.conf`](../contrib/lighttpd-docs-build-deny.conf) -Each is a drop-in snippet, not a full config file — merge it into your -existing site config near phpBB's own equivalent "deny access to -internal files" rule, don't replace your config with just that file. +The old default directory, `docs-build/` inside the extension, still ships +with an Apache `.htaccess` that denies all access. Boards upgraded from an +earlier version keep using it if a build was already there. Move that build +to `store/phpbbmodders_documentation/` and update the docs build path: +updating the extension replaces its directory and deletes the build. + +Each snippet is a drop-in, not a full config file. Merge it into your +existing site config near phpBB's own "deny access to internal files" rule; +don't replace your config with just that file. diff --git a/event/listener.php b/event/listener.php index 130fdf0..378773d 100644 --- a/event/listener.php +++ b/event/listener.php @@ -13,7 +13,9 @@ use phpbb\auth\auth; use phpbb\config\config; use phpbb\controller\helper as controller_helper; +use phpbb\language\language; use phpbb\template\template; +use phpbbmodders\documentation\acp\permission_sync; use phpbbmodders\documentation\controller\documentation_helper; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -26,8 +28,8 @@ * hides it; its documentation stays reachable by URL for anyone with the * underlying permissions. The master "enabled" setting is the one * exception: turning that off takes the documentation offline entirely, - * hiding both links and returning 503 from both routes (see - * documentation_controller). + * hiding both links and returning 503 from every documentation route + * (see documentation_controller). */ class listener implements EventSubscriberInterface { @@ -46,10 +48,13 @@ class listener implements EventSubscriberInterface /** @var template */ protected $template; - /** @var \phpbbmodders\documentation\acp\permission_sync */ + /** @var permission_sync */ protected $permission_sync; - public function __construct(auth $auth, config $config, controller_helper $controller_helper, documentation_helper $doc_helper, template $template, \phpbbmodders\documentation\acp\permission_sync $permission_sync) + /** @var language */ + protected $language; + + public function __construct(auth $auth, config $config, controller_helper $controller_helper, documentation_helper $doc_helper, template $template, permission_sync $permission_sync, language $language) { $this->auth = $auth; $this->config = $config; @@ -57,6 +62,7 @@ public function __construct(auth $auth, config $config, controller_helper $contr $this->doc_helper = $doc_helper; $this->template = $template; $this->permission_sync = $permission_sync; + $this->language = $language; } /** @@ -105,10 +111,10 @@ public function user_setup($event) * Registers a readable label for each currently-known section/language * permission on the ACP Permissions screen. The permission set is * dynamic (whatever sections/languages the docs build actually has), - * so unlike a normal extension's fixed ACL_* language keys, these - * labels are built at runtime and passed straight through as the + * so there is no fixed ACL_* key per permission. Each label is + * formatted here from a language string and passed through as the * 'lang' value — phpBB's lang() falls back to echoing an unresolved - * key verbatim, so a plain label works the same as a real key. + * key verbatim, so the formatted text is shown as-is. * * @param \phpbb\event\data $event * @return void @@ -118,14 +124,14 @@ public function add_permissions($event) foreach ($this->doc_helper->get_available_languages() as $lang) { $event->update_subarray('permissions', 'u_phpbbmodders_documentation_lang_' . $lang, array( - 'lang' => 'Documentation: view language "' . $lang . '"', + 'lang' => $this->language->lang('ACL_U_DOCUMENTATION_LANG', $lang), 'cat' => 'misc', )); foreach ($this->doc_helper->get_available_sections($lang) as $section) { $event->update_subarray('permissions', 'u_phpbbmodders_documentation_' . $section, array( - 'lang' => 'Documentation: view section "' . ucwords(str_replace(array('-', '_'), ' ', $section)) . '"', + 'lang' => $this->language->lang('ACL_U_DOCUMENTATION_SECTION', ucwords(str_replace(array('-', '_'), ' ', $section))), 'cat' => 'misc', )); } diff --git a/ext.php b/ext.php index 7f5d8d3..05be31e 100644 --- a/ext.php +++ b/ext.php @@ -14,10 +14,10 @@ class ext extends \phpbb\extension\base { /** * Check whether the extension can be enabled. - * The current phpBB version should meet or exceed - * the minimum version required by this extension. + * The current phpBB version must be within the range this extension + * supports, and PHP must meet the minimum version. * - * @return bool|array + * @return bool|string True, or the reason it can't be enabled. * @access public */ public function is_enableable() @@ -36,13 +36,14 @@ public function is_enableable() } /** - * Require phpBB 3.3.19 + * Require phpBB 3.3.19 or later within the 3.3 line * * @return bool */ public function check_phpbb_version() { - return phpbb_version_compare(PHPBB_VERSION, '3.3.19', '>='); + return phpbb_version_compare(PHPBB_VERSION, '3.3.19', '>=') + && phpbb_version_compare(PHPBB_VERSION, '4.0.0-dev', '<'); } /** diff --git a/language/en/documentation.php b/language/en/documentation.php index 1a82b4a..aed95f4 100644 --- a/language/en/documentation.php +++ b/language/en/documentation.php @@ -34,6 +34,10 @@ 'DOCUMENTATION_SEARCH_UNAVAILABLE' => 'Documentation search is unavailable. Rebuild the documentation to generate its search index.', 'DOCUMENTATION_SEARCH_LOADING' => 'Searching…', 'DOCUMENTATION_SEARCH_NEEDS_JS' => 'Documentation search requires JavaScript.', + 'DOCUMENTATION_LOGIN_EXPLAIN' => 'You need to log in to view this documentation.', + + 'ACL_U_DOCUMENTATION_LANG' => 'Documentation: view language “%s”', + 'ACL_U_DOCUMENTATION_SECTION' => 'Documentation: view section “%s”', 'NOTIFICATION_DOCUMENTATION_NO_BUILD' => 'No documentation build was found at the configured path.', 'NOTIFICATION_TYPE_DOCUMENTATION_NO_BUILD' => 'Documentation build missing', diff --git a/language/en/info_acp_documentation.php b/language/en/info_acp_documentation.php index 1726dde..708c018 100644 --- a/language/en/info_acp_documentation.php +++ b/language/en/info_acp_documentation.php @@ -72,6 +72,7 @@ 'ACP_DOCUMENTATION_DEVDOCS_NAV_ICON_EXPLAIN' => 'Optional Font Awesome icon name, such as fa-code. Used only with separate navigation links. Leave blank for no icon.', 'ACP_DOCUMENTATION_DETECTED' => 'Detected content', + 'ACP_DOCUMENTATION_IGNORED_NAMES' => 'These build directories were skipped because their names can’t be used in permission names: %s. Use only lowercase letters, digits, “_” and “-”, keep names short (up to 16 characters for a language, 21 for a section), and don’t start a section name with “lang_”.', 'ACP_DOCUMENTATION_LANG_CODE' => 'Language', 'ACP_DOCUMENTATION_LANG_SECTIONS' => 'Sections', diff --git a/language/en/install_documentation.php b/language/en/install_documentation.php index aed43a1..0e44226 100644 --- a/language/en/install_documentation.php +++ b/language/en/install_documentation.php @@ -18,7 +18,7 @@ if (empty($lang) || !is_array($lang)) { - $lang = []; + $lang = array(); } // DEVELOPERS PLEASE NOTE @@ -37,6 +37,6 @@ // ’ » “ ” … // -$lang = array_merge($lang, [ - 'DOCUMENTATION_NOT_ENABLEABLE' => 'Documentation could not be enabled. The minimum requirements of phpBB 3.3.19 and/or PHP 8.0.0 were not satisfied.', -]); +$lang = array_merge($lang, array( + 'DOCUMENTATION_NOT_ENABLEABLE' => 'Documentation could not be enabled. It requires phpBB 3.3.19 or a later 3.3 release, and PHP 8.0.0 or later.', +)); diff --git a/license.txt b/license.txt index d159169..b8b347f 100644 --- a/license.txt +++ b/license.txt @@ -279,61 +279,3 @@ POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. diff --git a/migrations/v10x/m7_store_docs_path.php b/migrations/v10x/m7_store_docs_path.php new file mode 100644 index 0000000..b3bf804 --- /dev/null +++ b/migrations/v10x/m7_store_docs_path.php @@ -0,0 +1,94 @@ +config['phpbbmodders_documentation_docs_path'] === self::OLD_DEFAULT_PATH + && !$this->has_build($this->phpbb_root_path . self::OLD_DEFAULT_PATH)) + { + $this->config->set('phpbbmodders_documentation_docs_path', self::NEW_DEFAULT_PATH); + } + } + + /** + * @return void + */ + public function restore_old_path() + { + if ($this->config['phpbbmodders_documentation_docs_path'] === self::NEW_DEFAULT_PATH) + { + $this->config->set('phpbbmodders_documentation_docs_path', self::OLD_DEFAULT_PATH); + } + } + + /** + * Same test as documentation_helper::get_docs_root(): at least one + * language directory with its own index.html. The extension's + * services can't be used here because they may not be loaded yet. + * + * @param string $dir + * @return bool + */ + protected function has_build($dir) + { + if (!is_dir($dir)) + { + return false; + } + + foreach (new \DirectoryIterator($dir) as $entry) + { + if (!$entry->isDot() && $entry->isDir() && is_file($entry->getPathname() . '/index.html')) + { + return true; + } + } + + return false; + } +} diff --git a/notification/type/no_docs_build.php b/notification/type/no_docs_build.php index 1b50280..8ddb41b 100644 --- a/notification/type/no_docs_build.php +++ b/notification/type/no_docs_build.php @@ -45,7 +45,7 @@ public function get_type() /** * {@inheritdoc} */ - static public $notification_option = array( + public static $notification_option = array( 'lang' => 'NOTIFICATION_TYPE_DOCUMENTATION_NO_BUILD', 'group' => 'NOTIFICATION_GROUP_ADMINISTRATION', ); @@ -64,7 +64,7 @@ public function is_available() * * {@inheritdoc} */ - static public function get_item_id($type_data) + public static function get_item_id($type_data) { return 1; } @@ -72,7 +72,7 @@ static public function get_item_id($type_data) /** * {@inheritdoc} */ - static public function get_item_parent_id($type_data) + public static function get_item_parent_id($type_data) { return 0; } @@ -153,7 +153,7 @@ public function get_title() */ public function get_url() { - return append_sid("{$this->phpbb_root_path}adm/index.{$this->php_ext}", 'i=-phpbbmodders-documentation-acp-main_module&mode=settings'); + return append_sid("{$this->phpbb_root_path}adm/index.{$this->php_ext}", 'i=-phpbbmodders-documentation-acp-main_module&mode=settings'); } /** diff --git a/styles/all/template/documentation_body.html b/styles/all/template/documentation_body.html index 60fe867..1c79995 100644 --- a/styles/all/template/documentation_body.html +++ b/styles/all/template/documentation_body.html @@ -18,7 +18,7 @@
{{ L_LANGUAGE }}{{ L_COLON }} {% for documentation_language in documentation_language %} - {{ documentation_language.LABEL }} + {{ documentation_language.LABEL|e }} {% endfor %}
{% endif %} diff --git a/styles/all/template/js/documentation-search.js b/styles/all/template/js/documentation-search.js index 08dfeb7..8be38fd 100644 --- a/styles/all/template/js/documentation-search.js +++ b/styles/all/template/js/documentation-search.js @@ -1,8 +1,9 @@ /* * Documentation full-text search results, powered by Pagefind. * - * Renders results into the element carrying data-doc-search. The phpBB - * Documentation extension ships a copy of this file; keep both in sync. + * Renders results into the element carrying data-doc-search. The + * standalone site in phpbbdocs-hugo ships its own copy of this file; keep + * the two in sync. * * Attributes on that element: * data-doc-search-bundles JSON array of Pagefind bundle URLs, each @@ -20,142 +21,142 @@ * Hugo paths to phpBB routes). */ (function () { - "use strict"; - - var MAX_RESULTS = 50; - var panel = document.querySelector("[data-doc-search]"); - - if (!panel) { - return; - } - - function readJson(name, fallback) { - try { - return JSON.parse(panel.getAttribute(name)) || fallback; - } catch (error) { - return fallback; - } - } - - var bundles = readJson("data-doc-search-bundles", []); - var messages = readJson("data-doc-search-messages", {}); - var links = readJson("data-doc-search-links", null); - var query = panel.getAttribute("data-doc-search-query"); - var baseUrl = panel.getAttribute("data-doc-search-base-url") || "/"; - - if (query === null) { - query = new URLSearchParams(window.location.search).get("q") || ""; - } - query = query.trim(); - - // Show the query in any empty search box on the page. - document.querySelectorAll("input[type=search][name=q]").forEach(function (input) { - if (!input.value) { - input.value = query; - } - }); - - var status = document.createElement("p"); - status.className = "doc-search-status"; - status.setAttribute("role", "status"); - panel.appendChild(status); - - function showStatus(text) { - status.textContent = text || ""; - status.hidden = !text; - } - - // Pagefind excerpts are HTML with around matches. Rebuild them - // from an inert template keeping only text and , so nothing else - // from the index reaches the page as markup. - function excerptNode(html) { - var template = document.createElement("template"); - var fragment = document.createDocumentFragment(); - - template.innerHTML = html || ""; - template.content.childNodes.forEach(function (node) { - if (node.nodeType === Node.ELEMENT_NODE && node.nodeName === "MARK") { - var mark = document.createElement("mark"); - mark.textContent = node.textContent; - fragment.appendChild(mark); - } else { - fragment.appendChild(document.createTextNode(node.textContent)); - } - }); - - return fragment; - } - - function resultUrl(url) { - if (links && links.prefix && url.indexOf(links.prefix) === 0) { - return links.template.replace(links.placeholder, url.slice(links.prefix.length).replace(/\/$/, "")); - } - return url; - } - - function render(results) { - if (!results.length) { - showStatus(messages.noResults); - return; - } - - var list = document.createElement("ol"); - list.className = "doc-search-results"; - - results.forEach(function (result) { - var item = document.createElement("li"); - var heading = document.createElement("h3"); - var link = document.createElement("a"); - var excerpt = document.createElement("p"); - - link.href = resultUrl(result.url); - link.textContent = (result.meta && result.meta.title) || result.url; - heading.appendChild(link); - excerpt.appendChild(excerptNode(result.excerpt)); - item.appendChild(heading); - item.appendChild(excerpt); - list.appendChild(item); - }); - - showStatus(""); - panel.appendChild(list); - } - - if (query.length < 2 || query.length > 100) { - showStatus(messages.length); - return; - } - - if (!bundles.length) { - showStatus(messages.unavailable); - return; - } - - showStatus(messages.loading); - - import(bundles[0] + "pagefind.js") - .then(function (pagefind) { - // Indexed paths are relative to the site root (//...), not to - // each bundle's own directory, which Pagefind would otherwise assume. - return pagefind.options({ basePath: bundles[0], baseUrl: baseUrl }) - .then(function () { - return bundles.slice(1).reduce(function (merged, bundle) { - return merged.then(function () { - return pagefind.mergeIndex(bundle, { baseUrl: baseUrl }); - }); - }, Promise.resolve()); - }) - .then(function () { - return pagefind.search(query); - }); - }) - .then(function (search) { - return Promise.all(search.results.slice(0, MAX_RESULTS).map(function (result) { - return result.data(); - })); - }) - .then(render) - .catch(function () { - showStatus(messages.unavailable); - }); + "use strict"; + + var MAX_RESULTS = 50; + var panel = document.querySelector("[data-doc-search]"); + + if (!panel) { + return; + } + + function readJson(name, fallback) { + try { + return JSON.parse(panel.getAttribute(name)) || fallback; + } catch (error) { + return fallback; + } + } + + var bundles = readJson("data-doc-search-bundles", []); + var messages = readJson("data-doc-search-messages", {}); + var links = readJson("data-doc-search-links", null); + var query = panel.getAttribute("data-doc-search-query"); + var baseUrl = panel.getAttribute("data-doc-search-base-url") || "/"; + + if (query === null) { + query = new URLSearchParams(window.location.search).get("q") || ""; + } + query = query.trim(); + + // Show the query in any empty search box on the page. + document.querySelectorAll("input[type=search][name=q]").forEach(function (input) { + if (!input.value) { + input.value = query; + } + }); + + var status = document.createElement("p"); + status.className = "doc-search-status"; + status.setAttribute("role", "status"); + panel.appendChild(status); + + function showStatus(text) { + status.textContent = text || ""; + status.hidden = !text; + } + + // Pagefind excerpts are HTML with around matches. Rebuild them + // from an inert template keeping only text and , so nothing else + // from the index reaches the page as markup. + function excerptNode(html) { + var template = document.createElement("template"); + var fragment = document.createDocumentFragment(); + + template.innerHTML = html || ""; + template.content.childNodes.forEach(function (node) { + if (node.nodeType === Node.ELEMENT_NODE && node.nodeName === "MARK") { + var mark = document.createElement("mark"); + mark.textContent = node.textContent; + fragment.appendChild(mark); + } else { + fragment.appendChild(document.createTextNode(node.textContent)); + } + }); + + return fragment; + } + + function resultUrl(url) { + if (links && links.prefix && url.indexOf(links.prefix) === 0) { + return links.template.replace(links.placeholder, url.slice(links.prefix.length).replace(/\/$/, "")); + } + return url; + } + + function render(results) { + if (!results.length) { + showStatus(messages.noResults); + return; + } + + var list = document.createElement("ol"); + list.className = "doc-search-results"; + + results.forEach(function (result) { + var item = document.createElement("li"); + var heading = document.createElement("h3"); + var link = document.createElement("a"); + var excerpt = document.createElement("p"); + + link.href = resultUrl(result.url); + link.textContent = (result.meta && result.meta.title) || result.url; + heading.appendChild(link); + excerpt.appendChild(excerptNode(result.excerpt)); + item.appendChild(heading); + item.appendChild(excerpt); + list.appendChild(item); + }); + + showStatus(""); + panel.appendChild(list); + } + + if (query.length < 2 || query.length > 100) { + showStatus(messages.length); + return; + } + + if (!bundles.length) { + showStatus(messages.unavailable); + return; + } + + showStatus(messages.loading); + + import(bundles[0] + "pagefind.js") + .then(function (pagefind) { + // Indexed paths are relative to the site root (//...), not to + // each bundle's own directory, which Pagefind would otherwise assume. + return pagefind.options({ basePath: bundles[0], baseUrl: baseUrl }) + .then(function () { + return bundles.slice(1).reduce(function (merged, bundle) { + return merged.then(function () { + return pagefind.mergeIndex(bundle, { baseUrl: baseUrl }); + }); + }, Promise.resolve()); + }) + .then(function () { + return pagefind.search(query); + }); + }) + .then(function (search) { + return Promise.all(search.results.slice(0, MAX_RESULTS).map(function (result) { + return result.data(); + })); + }) + .then(render) + .catch(function () { + showStatus(messages.unavailable); + }); }()); diff --git a/styles/all/template/js/documentation-tooltips.js b/styles/all/template/js/documentation-tooltips.js index d532f3a..4359b43 100644 --- a/styles/all/template/js/documentation-tooltips.js +++ b/styles/all/template/js/documentation-tooltips.js @@ -1,6 +1,15 @@ (function () { "use strict"; + // A malformed attribute must not stop the remaining tooltips working. + function parseJson(value) { + try { + return JSON.parse(value || "{}") || {}; + } catch (error) { + return {}; + } + } + function annotate(node, explanation) { var term = document.createElement("span"); term.className = "documentation-tooltip-term"; @@ -11,8 +20,8 @@ } document.querySelectorAll("[data-doc-abbreviations]").forEach(function (article) { - var definitions = JSON.parse(article.dataset.docAbbreviations); - var headings = JSON.parse(article.dataset.docEventHeadings || "{}"); + var definitions = parseJson(article.dataset.docAbbreviations); + var headings = parseJson(article.dataset.docEventHeadings); var seen = Object.create(null); var walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT); var nodes = []; @@ -27,7 +36,9 @@ var offset = 0; var match; while ((match = pattern.exec(node.textContent))) { - if (seen[match[0]] || !definitions[match[0]]) continue; + if (seen[match[0]] || !definitions[match[0]]) { + continue; + } seen[match[0]] = true; fragment.appendChild(document.createTextNode(node.textContent.slice(offset, match.index))); var text = document.createTextNode(match[0]); @@ -50,10 +61,16 @@ }); article.querySelectorAll("img[alt]").forEach(function (image) { var description = image.getAttribute("alt").trim(); - if (!description) return; + if (!description) { + return; + } var target = image.closest("a") || image; - if (!target.dataset.docTooltip) target.dataset.docTooltip = description; - if (target === image) target.tabIndex = 0; + if (!target.dataset.docTooltip) { + target.dataset.docTooltip = description; + } + if (target === image) { + target.tabIndex = 0; + } target.classList.add("documentation-tooltip-image"); }); }); @@ -73,8 +90,11 @@ var ids = (active.getAttribute("aria-describedby") || "").split(/\s+/).filter(function (id) { return id && id !== popup.id; }); - if (ids.length) active.setAttribute("aria-describedby", ids.join(" ")); - else active.removeAttribute("aria-describedby"); + if (ids.length) { + active.setAttribute("aria-describedby", ids.join(" ")); + } else { + active.removeAttribute("aria-describedby"); + } } active = null; popup.hidden = true; @@ -90,14 +110,18 @@ var rect = target.getBoundingClientRect(); var left = Math.max(12, Math.min(rect.left, window.innerWidth - popup.offsetWidth - 12)); var top = rect.bottom + 6; - if (top + popup.offsetHeight > window.innerHeight - 12) top = Math.max(12, rect.top - popup.offsetHeight - 6); + if (top + popup.offsetHeight > window.innerHeight - 12) { + top = Math.max(12, rect.top - popup.offsetHeight - 6); + } popup.style.left = left + "px"; popup.style.top = top + "px"; } function scheduleHide() { timer = setTimeout(function () { - if (active !== document.activeElement) hide(); + if (active !== document.activeElement) { + hide(); + } }, 150); } @@ -110,9 +134,15 @@ }); popup.addEventListener("mouseenter", function () { clearTimeout(timer); }); popup.addEventListener("mouseleave", scheduleHide); - document.addEventListener("keydown", function (event) { if (event.key === "Escape") hide(); }); + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + hide(); + } + }); document.addEventListener("click", function (event) { - if (!event.target.closest("[data-doc-tooltip], .documentation-tooltip-popup")) hide(); + if (!event.target.closest("[data-doc-tooltip], .documentation-tooltip-popup")) { + hide(); + } }); window.addEventListener("resize", hide); document.addEventListener("scroll", hide, true); diff --git a/tests/controller/documentation_controller_test.php b/tests/controller/documentation_controller_test.php index 87e098e..5738b07 100644 --- a/tests/controller/documentation_controller_test.php +++ b/tests/controller/documentation_controller_test.php @@ -57,7 +57,7 @@ protected function tearDown(): void rmdir($this->root); } - protected function get_controller(array $denied = array(), $query = '', array $overrides = array(), $scope = '') + protected function get_controller(array $denied = array(), $query = '', array $overrides = array(), $scope = '', $is_registered = true) { $config = new config(array_merge(array( 'phpbbmodders_documentation_enabled' => 1, @@ -75,7 +75,7 @@ protected function get_controller(array $denied = array(), $query = '', array $o return $name === 'q' ? $query : ($name === 'scope' ? $scope : $default); }); $user = $this->createMock(user::class); - $user->data = array('is_registered' => true); + $user->data = array('is_registered' => $is_registered); $routes = $this->createMock(controller_helper::class); $routes->method('route')->willReturnCallback(function ($name, $params) { if ($name === 'phpbbmodders_documentation_search_bundle') @@ -92,7 +92,19 @@ protected function get_controller(array $denied = array(), $query = '', array $o $helper = $this->getMockBuilder(documentation_helper::class) ->setConstructorArgs(array($config, $this->createMock(language::class), $request, $user, '', $routes)) ->onlyMethods(array('set_language_cookie'))->getMock(); - return new documentation_controller($auth, $config, $routes, $template, $user, $helper, '', 'php', $request); + $language = $this->createMock(language::class); + $language->method('lang')->willReturnArgument(0); + if (!$is_registered) + { + // login_box() renders the login page and ends the request. + return new class($auth, $config, $routes, $template, $user, $helper, $request, $language) extends documentation_controller { + protected function show_login_box() + { + throw new \RuntimeException('login_box'); + } + }; + } + return new documentation_controller($auth, $config, $routes, $template, $user, $helper, $request, $language); } protected function make_bundle($lang, $section) @@ -288,4 +300,36 @@ public static function rejected_page_provider() 'cross-language traversal' => array('quickstart/../../en/quickstart/install', array('u_phpbbmodders_documentation_lang_en'), 404), ); } + + public function test_guest_without_access_gets_the_login_box() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('login_box'); + $this->get_controller(array('u_phpbbmodders_documentation_lang_en'), '', array(), '', false)->handle('en', 'quickstart'); + } + + public function test_registered_user_without_access_gets_403() + { + try + { + $this->get_controller(array('u_phpbbmodders_documentation_lang_en'))->handle('en', 'quickstart'); + $this->fail('A registered user without access must get a 403'); + } + catch (http_exception $exception) + { + $this->assertSame(403, $exception->getStatusCode()); + } + } + + public function test_search_messages_come_from_the_language_service() + { + $this->make_bundle('en', 'quickstart'); + $this->get_controller(array(), 'database')->search('en'); + $this->assertSame(array( + 'length' => 'DOCUMENTATION_SEARCH_LENGTH', + 'noResults' => 'DOCUMENTATION_SEARCH_NO_RESULTS', + 'unavailable' => 'DOCUMENTATION_SEARCH_UNAVAILABLE', + 'loading' => 'DOCUMENTATION_SEARCH_LOADING', + ), json_decode($this->assigned['DOCUMENTATION_SEARCH_MESSAGES'], true)); + } } diff --git a/tests/controller/documentation_helper_test.php b/tests/controller/documentation_helper_test.php index c93f652..887afc3 100644 --- a/tests/controller/documentation_helper_test.php +++ b/tests/controller/documentation_helper_test.php @@ -657,4 +657,61 @@ public function test_resolve_default_language_uses_a_configured_override_when_no ); $this->assertSame('da', $helper_with_override->resolve_default_language()); } + + public function test_unusable_directory_names_are_skipped_and_reported() + { + // Too long for auth_option (29-character prefix + 23 = 52), not + // lowercase, or colliding with a language permission. + $this->make_page('en', 'a_section_name_too_long'); + $this->make_page('en', 'Upper'); + $this->make_page('en', 'lang_da'); + $this->make_page('en', 'a_section_exactly_21c'); + $this->make_page('Fr', ''); + + $helper = $this->get_helper(); + + $this->assertSame(array('da', 'en'), $helper->get_available_languages()); + $this->assertSame(array('a_section_exactly_21c', 'quickstart'), $helper->get_available_sections('en')); + $this->assertSame(array('Fr', 'en/Upper', 'en/a_section_name_too_long', 'en/lang_da'), $helper->get_ignored_build_names()); + $this->assertFalse($helper->get_content_file('Fr', '')); + } + + public function test_language_cookie_uses_the_boards_cookie_settings() + { + $user = $this->get_user(); + $user->expects($this->once())->method('set_cookie') + ->with('docs_lang', 'da', $this->greaterThan(time() + documentation_helper::COOKIE_LIFETIME - 60)); + $request = $this->get_request(); + $request->expects($this->once())->method('overwrite') + ->with('phpbb3_docs_lang', 'da', request_interface::COOKIE); + $helper = new documentation_helper( + $this->get_config(array('cookie_name' => 'phpbb3')), + $this->createMock(language::class), + $request, + $user, + '', + $this->get_controller_helper() + ); + + $helper->set_language_cookie('da'); + } + + public function test_resolve_default_language_reads_the_prefixed_cookie() + { + $request = $this->createMock(request_interface::class); + $request->method('variable')->willReturnCallback(function ($name, $default) { + return $name === 'phpbb3_docs_lang' ? 'da' : $default; + }); + $request->method('header')->willReturn(''); + $helper = new documentation_helper( + $this->get_config(array('cookie_name' => 'phpbb3')), + $this->createMock(language::class), + $request, + $this->get_user(), + '', + $this->get_controller_helper() + ); + + $this->assertSame('da', $helper->resolve_default_language()); + } } diff --git a/tests/controller/documentation_image_test.php b/tests/controller/documentation_image_test.php index 2eaf239..1d05e10 100644 --- a/tests/controller/documentation_image_test.php +++ b/tests/controller/documentation_image_test.php @@ -35,12 +35,14 @@ protected function setUp(): void mkdir($this->root . '/' . $lang . '/images'); file_put_contents($this->root . '/' . $lang . '/index.html', '
Home
'); file_put_contents($this->root . '/' . $lang . '/quickstart/index.html', '
Section
'); - file_put_contents($this->root . '/' . $lang . '/quickstart/install/index.html', '
'); + file_put_contents($this->root . '/' . $lang . '/quickstart/install/index.html', '
'); } $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l9sAAAAASUVORK5CYII='); file_put_contents($this->root . '/en/images/referenced.png', $png); file_put_contents($this->root . '/en/images/private.png', $png); file_put_contents($this->root . '/en/images/nonimage.png', ''); + // Short enough that getimagesize() itself would raise a notice. + file_put_contents($this->root . '/en/images/tiny.png', "\x89PNG"); file_put_contents($this->root . '/outside.png', $png); symlink($this->root . '/outside.png', $this->root . '/en/images/escape.png'); } @@ -75,7 +77,7 @@ protected function get_controller(array $params = array(), array $denied = array $user = $this->createMock(user::class); $routes = $this->createMock(controller_helper::class); $helper = new documentation_helper($config, $this->createMock(language::class), $request, $user, '', $routes); - return new documentation_controller($auth, $config, $routes, $this->createMock(template::class), $user, $helper, '', 'php', $request); + return new documentation_controller($auth, $config, $routes, $this->createMock(template::class), $user, $helper, $request, $this->createMock(language::class)); } public function test_referenced_image_returns_binary_bytes_and_private_headers() @@ -121,6 +123,7 @@ public static function rejected_request_provider() 'unreferenced image' => array('en', 'images/private.png', array(), array(), array(), 404), 'missing image' => array('en', 'images/missing.png', array(), array(), array(), 404), 'nonimage content' => array('en', 'images/nonimage.png', array(), array(), array(), 404), + 'too short to be an image' => array('en', 'images/tiny.png', array(), array(), array(), 404), 'unknown language' => array('fr', 'images/referenced.png', array(), array(), array(), 404), 'image traversal' => array('en', 'images/../../outside.png', array(), array(), array(), 404), 'page traversal' => array('en', 'images/referenced.png', array('page' => 'quickstart/../../da/quickstart/install'), array(), array(), 404), diff --git a/tests/event/listener_test.php b/tests/event/listener_test.php index 5104ac9..6b5a9dd 100644 --- a/tests/event/listener_test.php +++ b/tests/event/listener_test.php @@ -101,7 +101,11 @@ protected function make_listener(array $config_overrides, array $allowed_permiss }); $language = $this->createMock(language::class); - $language->method('lang')->willReturnCallback(function ($key) { + $language->method('lang')->willReturnCallback(function ($key, ...$args) { + if (strpos($key, 'ACL_') === 0) + { + return $key . ':' . implode(',', $args); + } return $key === 'DOCUMENTATION_DEV' ? 'Developer Documentation' : 'Documentation'; }); @@ -125,7 +129,8 @@ protected function make_listener(array $config_overrides, array $allowed_permiss $controller_helper, $doc_helper, $template, - $this->createMock(permission_sync::class) + $this->createMock(permission_sync::class), + $language ); } @@ -262,4 +267,19 @@ public function test_navigation_template_renders_icons_only_when_assigned() $this->assertStringContainsString(' Documentation', $with_icons); $this->assertStringContainsString(' Developer Documentation', $with_icons); } + + public function test_permission_labels_come_from_the_language_file() + { + $assigned = array(); + $listener = $this->make_listener(array(), array(), $assigned); + $event = new \phpbb\event\data(array('permissions' => array())); + + $listener->add_permissions($event); + + $this->assertSame(array( + 'u_phpbbmodders_documentation_lang_en' => array('lang' => 'ACL_U_DOCUMENTATION_LANG:en', 'cat' => 'misc'), + 'u_phpbbmodders_documentation_development' => array('lang' => 'ACL_U_DOCUMENTATION_SECTION:Development', 'cat' => 'misc'), + 'u_phpbbmodders_documentation_quickstart' => array('lang' => 'ACL_U_DOCUMENTATION_SECTION:Quickstart', 'cat' => 'misc'), + ), $event['permissions']); + } } diff --git a/tests/migrations/m7_store_docs_path_test.php b/tests/migrations/m7_store_docs_path_test.php new file mode 100644 index 0000000..066e559 --- /dev/null +++ b/tests/migrations/m7_store_docs_path_test.php @@ -0,0 +1,105 @@ +root = sys_get_temp_dir() . '/documentation_m7_' . uniqid() . '/'; + mkdir($this->root . m7_store_docs_path::OLD_DEFAULT_PATH, 0777, true); + } + + protected function tearDown(): void + { + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) + { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->root); + } + + /** + * @param string $docs_path + * @return array [migration, config] + */ + protected function get_migration($docs_path) + { + $config = $this->getMockBuilder(config::class) + ->setConstructorArgs(array(array('phpbbmodders_documentation_docs_path' => $docs_path))) + ->onlyMethods(array('set')) + ->getMock(); + $config->method('set')->willReturnCallback(function ($key, $value) use ($config) { + $config->offsetSet($key, $value); + }); + + $migration = new m7_store_docs_path( + $config, + $this->createMock(driver_interface::class), + $this->createMock(tools_interface::class), + $this->root, + 'php', + 'phpbb_' + ); + + return array($migration, $config); + } + + public function test_old_default_without_a_build_moves_to_store() + { + list($migration, $config) = $this->get_migration(m7_store_docs_path::OLD_DEFAULT_PATH); + + $migration->use_store_path(); + + $this->assertSame(m7_store_docs_path::NEW_DEFAULT_PATH, $config['phpbbmodders_documentation_docs_path']); + } + + public function test_existing_build_at_old_default_is_kept() + { + mkdir($this->root . m7_store_docs_path::OLD_DEFAULT_PATH . '/en'); + file_put_contents($this->root . m7_store_docs_path::OLD_DEFAULT_PATH . '/en/index.html', 'page'); + list($migration, $config) = $this->get_migration(m7_store_docs_path::OLD_DEFAULT_PATH); + + $migration->use_store_path(); + + $this->assertSame(m7_store_docs_path::OLD_DEFAULT_PATH, $config['phpbbmodders_documentation_docs_path']); + } + + public function test_custom_path_is_kept() + { + list($migration, $config) = $this->get_migration('/srv/docs'); + + $migration->use_store_path(); + + $this->assertSame('/srv/docs', $config['phpbbmodders_documentation_docs_path']); + } + + public function test_revert_restores_only_the_new_default() + { + list($migration, $config) = $this->get_migration(m7_store_docs_path::NEW_DEFAULT_PATH); + $migration->restore_old_path(); + $this->assertSame(m7_store_docs_path::OLD_DEFAULT_PATH, $config['phpbbmodders_documentation_docs_path']); + + list($migration, $config) = $this->get_migration('/srv/docs'); + $migration->restore_old_path(); + $this->assertSame('/srv/docs', $config['phpbbmodders_documentation_docs_path']); + } +}
{{ documentation_lang.CODE }}{{ documentation_lang.SECTIONS }}{{ documentation_lang.CODE|e }}{{ documentation_lang.SECTIONS|e }}