diff --git a/README.md b/README.md index 1b723fa..3eea023 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,27 @@ A knowledge base for phpBB: articles organised in categories, with attachments, - - + + - - + +
The flat article index
The flat article index (the default)
The category tree
Hierarchical category browsing
The flat article index
The flat article index (the default)
The category tree
Hierarchical category browsing
An article
An article
Knowledge Base articles beside the forum posts in a board search
Knowledge Base block in the board search
An article
An article
Knowledge Base articles beside the forum posts in a board search
Knowledge Base block in the board search
More screenshots, including the article form, the approval queue and the ACP settings: [docs/images/screenshots](docs/images/screenshots/README.md). +Primary authorship of submitted articles can be transferred to another active +member or a configured KB team using **Change author** on the article page. +Grant moderators **Can change the primary author of submitted articles** in +Knowledge Base category permissions. KB administrators already have access. +Transfers are logged and retain co-authors and submitted revisions. Private +drafts and articles with private draft edits must be submitted or discarded +before authorship can change. + +Article pages show status below the category. Category lists and review queues also show status, pending revisions, and “Has draft” for authors and authorized editors. Draft contents remain private. Markdown search excerpts retain their formatting. + ## Installation 1. Copy the extension to: `/ext/phpbbmodders/knowledgebase` @@ -86,6 +96,7 @@ Based on the original [knowledgebase](https://github.com/sutrus/knowledgebase) e - Code review, bug fixes, and documentation assisted by [Claude](https://www.anthropic.com/claude). - Code review, bug fixes, and documentation assisted by [Codex](https://openai.com/codex/). + ## License Licensed under the [GNU General Public License v2](license.txt) diff --git a/config/routing.yml b/config/routing.yml index 9575965..4e75645 100644 --- a/config/routing.yml +++ b/config/routing.yml @@ -42,6 +42,10 @@ phpbbmodders_knowledgebase_article: path: /knowledgebase/article defaults: { _controller: phpbbmodders.knowledgebase.controller.article:show } +phpbbmodders_knowledgebase_change_author: + path: /knowledgebase/change_author + defaults: { _controller: phpbbmodders.knowledgebase.controller.change_author:main } + phpbbmodders_knowledgebase_posting: path: /knowledgebase/posting defaults: { _controller: phpbbmodders.knowledgebase.controller.posting:post_article } diff --git a/config/services.yml b/config/services.yml index 74b58f6..7810dd4 100644 --- a/config/services.yml +++ b/config/services.yml @@ -70,6 +70,32 @@ services: - '%tables.articles_table%' - '%tables.kb_attachments_table%' + phpbbmodders.knowledgebase.author_change: + class: phpbbmodders\knowledgebase\inc\author_change + arguments: + - '@dbal.conn' + - '@cache.driver' + - '@log' + - '@user' + - '%tables.articles_table%' + - '%tables.kb_article_co_authors_table%' + - '%tables.kb_draft_edits_table%' + - '%tables.search_result_table%' + + phpbbmodders.knowledgebase.controller.change_author: + class: phpbbmodders\knowledgebase\controller\change_author + arguments: + - '@dbal.conn' + - '@controller.helper' + - '@language' + - '@auth' + - '@request' + - '@template' + - '@user' + - '@phpbbmodders.knowledgebase.inc' + - '@phpbbmodders.knowledgebase.author_change' + - '%tables.articles_table%' + phpbbmodders.knowledgebase.controller.posting: class: phpbbmodders\knowledgebase\controller\posting arguments: diff --git a/controller/approve.php b/controller/approve.php index ca12ec0..858e4f9 100644 --- a/controller/approve.php +++ b/controller/approve.php @@ -235,6 +235,8 @@ public function queue(): \Symfony\Component\HttpFoundation\Response $this->db->sql_freeresult($result); $author_colours = $this->kb->get_author_colours_bulk($art_rows); + $edit_flags = $this->kb->get_article_edit_flags_bulk(array_column($art_rows, 'article_id')); + $is_own_article_map = $this->kb->is_article_author_bulk($art_rows); // Same options for every unassigned row below - computed once // rather than re-querying the team group list per row. @@ -248,7 +250,7 @@ public function queue(): \Symfony\Component\HttpFoundation\Response // A revision survives deactivation - a moderator pulling a live // article offline over a contentious edit still needs to see // and act on it, not just get "Reactivate". - $has_revision = in_array($status, [functions_kb::STATUS_ACTIVE, functions_kb::STATUS_INACTIVE], true) && $this->kb->article_has_pending_revision($art_id); + $has_revision = in_array($status, [functions_kb::STATUS_ACTIVE, functions_kb::STATUS_INACTIVE], true) && !empty($edit_flags[$art_id]['revision']); $assigned_team_id = (int) $art_row['assigned_team_id']; $effective_team_id = $assigned_team_id ?: (int) ($category_info['default_team_id'] ?? 0); @@ -265,6 +267,8 @@ public function queue(): \Symfony\Component\HttpFoundation\Response 'S_STATUS_ACTIVE' => $status === functions_kb::STATUS_ACTIVE, 'S_STATUS_INACTIVE' => $status === functions_kb::STATUS_INACTIVE, 'S_HAS_REVISION' => $has_revision, + 'STATUS' => $status, + 'S_HAS_DRAFT_EDIT' => !empty($edit_flags[$art_id]['draft']) && (!empty($is_own_article_map[$art_id]) || $this->auth->acl_get('a_manage_kb') || $this->kb->acl_kb_get_any($this->kb->get_article_categories($art_id), 'kb_m_edit')), // Whether an explicit per-article team is set - controls // the "Assign" button, independent of the category's // default team below. diff --git a/controller/article.php b/controller/article.php index 5fc796a..fd9ec63 100644 --- a/controller/article.php +++ b/controller/article.php @@ -305,6 +305,8 @@ public function show(): \Symfony\Component\HttpFoundation\Response 'S_CAN_EDIT' => $this->kb->acl_kb_get_any($article_categories, 'kb_m_edit') || ($is_own_article && $this->kb->acl_kb_get_any($article_categories, 'kb_u_edit') || $this->auth->acl_get('a_manage_kb')), 'S_CAN_DELETE' => $this->kb->acl_kb_get_any($article_categories, 'kb_m_delete') || ($is_own_article && $this->kb->acl_kb_get_any($article_categories, 'kb_u_delete') || $this->auth->acl_get('a_manage_kb')), 'S_CAN_APPROVE' => $can_moderate || $can_review, + 'U_CHANGE_AUTHOR' => $status !== functions_kb::STATUS_DRAFT && $this->kb->acl_kb_get_any($article_categories, 'kb_m_change_author') + ? $this->helper->route('phpbbmodders_knowledgebase_change_author', ['k' => $art_id]) : '', 'COUNT_COMMENTS' => ($comment_topic_id) ? '[' . $this->language->lang('LEAVE_COMMENTS') . ']' : '', 'U_FORUM' => generate_board_url() . '/', 'S_APPROVED' => $row['approved'], @@ -316,11 +318,8 @@ public function show(): \Symfony\Component\HttpFoundation\Response 'S_STATUS_INACTIVE' => $status === functions_kb::STATUS_INACTIVE, 'S_STATUS_DRAFT' => $status === functions_kb::STATUS_DRAFT, 'S_HAS_REVISION' => in_array($status, [functions_kb::STATUS_ACTIVE, functions_kb::STATUS_INACTIVE], true) && $this->kb->article_has_pending_revision($art_id), - // Only the author/co-authors ever reach this branch for a - // draft-of-an-edit to matter - anyone else viewing the page - // couldn't act on it anyway, and a draft article itself can - // never have one (see posting.php). - 'S_HAS_DRAFT_EDIT' => $is_own_article && $status !== functions_kb::STATUS_DRAFT && $this->kb->article_has_draft_edit($art_id), + // Expose only the existence to editors; draft content remains private. + 'S_HAS_DRAFT_EDIT' => ($is_own_article || $this->kb->acl_kb_get_any($article_categories, 'kb_m_edit') || $this->auth->acl_get('a_manage_kb')) && $status !== functions_kb::STATUS_DRAFT && $this->kb->article_has_draft_edit($art_id), ]); if ($mode != 'print' && $row['approved']) diff --git a/controller/category.php b/controller/category.php index e377737..dd75335 100644 --- a/controller/category.php +++ b/controller/category.php @@ -371,6 +371,7 @@ public function cat(): \Symfony\Component\HttpFoundation\Response }, $art_rows); $is_own_article_map = $this->kb->is_article_author_bulk($art_rows); $co_authors_display_map = $this->kb->get_co_authors_display_bulk($art_ids); + $edit_flags = $this->kb->get_article_edit_flags_bulk($art_ids); foreach ($art_rows as $art_row) { @@ -400,6 +401,8 @@ public function cat(): \Symfony\Component\HttpFoundation\Response 'S_CAN_DELETE' => $this->kb->acl_kb_get_any($article_categories, 'kb_m_delete') || ($is_own_article && $this->kb->acl_kb_get_any($article_categories, 'kb_u_delete')), 'S_CAN_EDIT' => $this->kb->acl_kb_get_any($article_categories, 'kb_m_edit') || ($is_own_article && $this->kb->acl_kb_get_any($article_categories, 'kb_u_edit')), 'S_APPROVED' => (bool) $art_row['approved'], + 'S_HAS_REVISION' => in_array((int) $art_row['status'], [functions_kb::STATUS_ACTIVE, functions_kb::STATUS_INACTIVE], true) && !empty($edit_flags[(int) $art_id]['revision']), + 'S_HAS_DRAFT_EDIT' => ($is_own_article || $this->kb->acl_kb_get_any($article_categories, 'kb_m_edit') || $this->auth->acl_get('a_manage_kb')) && !empty($edit_flags[(int) $art_id]['draft']), 'STATUS' => (int) $art_row['status'], 'S_CROSS_LISTED' => !$flat_all && (int) $art_row['article_category_id'] !== (int) $cat_id, 'S_IS_REDIRECT' => (bool) $art_row['is_redirect'], diff --git a/controller/change_author.php b/controller/change_author.php new file mode 100644 index 0000000..d7435f6 --- /dev/null +++ b/controller/change_author.php @@ -0,0 +1,142 @@ +user->data['user_id'] === ANONYMOUS || + (!$this->auth->acl_get('u_kb_view') && !$this->auth->acl_get('a_manage_kb'))) + { + return new Response($this->language->lang('NOT_AUTHORISED'), 403); + } + $id = $this->request->variable('k', 0); + $result = $this->db->sql_query('SELECT * FROM ' . $this->articles_table . ' WHERE article_id = ' . (int) $id); + $article = $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + if (!$article || (int) $article['status'] === functions_kb::STATUS_DRAFT) + { + return new Response($this->language->lang('ARTICLE_NO_EXISTS'), 404); + } + if (!$this->kb->acl_kb_get_any($this->kb->get_article_categories($id), 'kb_m_change_author')) + { + return new Response($this->language->lang('NOT_AUTHORISED'), 403); + } + if ($this->kb->article_has_draft_edit($id)) + { + $this->template->assign_vars([ + 'NOTICE_MESSAGE' => $this->language->lang('KB_CHANGE_AUTHOR_PRIVATE_DRAFT'), + 'S_KNOWLEDGEBASE' => true, + ]); + $this->kb->assign_nav_tabs_vars('', 0, $this->language->lang('KB_TAB_NOTICE')); + return $this->helper->render('@phpbbmodders_knowledgebase/kb_notice_body.html', $this->language->lang('KB_TAB_NOTICE'), 409); + } + + $errors = []; + $type = $this->request->variable('author_type', functions_kb::AUTHOR_TYPE_USER); + $username = $this->request->variable('username', '', true); + $group_id = $this->request->variable('group_id', 0); + $teams = $this->kb->get_kb_team_group_names(); + if ($this->request->is_set_post('submit')) + { + if (!check_form_key('kb_change_author')) + { + $errors[] = $this->language->lang('FORM_INVALID'); + } + if ($this->request->variable('old_author_id', -1) !== (int) $article['author_id'] || + $this->request->variable('old_author_type', -1) !== (int) $article['author_type']) + { + $errors[] = $this->language->lang('KB_CHANGE_AUTHOR_CONFLICT'); + } + $target = $this->resolve_author($type, $username, $group_id, $teams); + if (!$target) + { + $errors[] = $this->language->lang('KB_CHANGE_AUTHOR_INVALID'); + } + if (!$errors) + { + try + { + $this->author_change->transfer($article, $target['id'], $type, $target['name']); + return new RedirectResponse($this->helper->route('phpbbmodders_knowledgebase_article', ['k' => $id])); + } + catch (\RuntimeException $error) + { + if (!in_array($error->getMessage(), ['KB_CHANGE_AUTHOR_PRIVATE_DRAFT', 'KB_CHANGE_AUTHOR_CONFLICT', 'KB_CHANGE_AUTHOR_LOG_FAILED'], true)) + { + throw $error; + } + $errors[] = $this->language->lang($error->getMessage()); + } + } + } + add_form_key('kb_change_author'); + foreach ($teams as $team_id => $name) + { + $this->template->assign_block_vars('author_team', ['ID' => $team_id, 'NAME' => $name, 'S_SELECTED' => $team_id === $group_id]); + } + $this->kb->assign_nav_tabs_vars('', 0, $this->language->lang('KB_CHANGE_AUTHOR')); + $this->template->assign_vars([ + 'S_KNOWLEDGEBASE' => true, + 'ARTICLE_TITLE' => $article['article_title'], + 'ARTICLE_AUTHOR' => $this->kb->get_author_display($article), + 'OLD_AUTHOR_ID' => $article['author_id'], 'OLD_AUTHOR_TYPE' => $article['author_type'], + 'AUTHOR_TYPE' => $type, 'AUTHOR_USERNAME' => $username, + 'ERROR' => implode('
', $errors), + 'U_CHANGE_AUTHOR' => $this->helper->route('phpbbmodders_knowledgebase_change_author', ['k' => $id]), + 'U_ARTICLE' => $this->helper->route('phpbbmodders_knowledgebase_article', ['k' => $id]), + ]); + return $this->helper->render('@phpbbmodders_knowledgebase/kb_change_author_body.html', $this->language->lang('KB_CHANGE_AUTHOR')); + } + + /** Resolve only active human accounts or an explicitly configured KB team. */ + protected function resolve_author(int $type, string $username, int $group_id, array $teams): ?array + { + if ($type === functions_kb::AUTHOR_TYPE_GROUP) + { + return isset($teams[$group_id]) ? ['id' => $group_id, 'name' => $teams[$group_id]] : null; + } + if ($type !== functions_kb::AUTHOR_TYPE_USER || trim($username) === '') + { + return null; + } + $result = $this->db->sql_query('SELECT user_id, username FROM ' . USERS_TABLE . " + WHERE username_clean = '" . $this->db->sql_escape(utf8_clean_string($username)) . "' + AND user_type IN (" . USER_NORMAL . ', ' . USER_FOUNDER . ') + AND user_id <> ' . ANONYMOUS); + $row = $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + return $row ? ['id' => (int) $row['user_id'], 'name' => $row['username']] : null; + } +} diff --git a/controller/search.php b/controller/search.php index 1c30769..a0df3f8 100644 --- a/controller/search.php +++ b/controller/search.php @@ -339,20 +339,28 @@ public function main(): \Symfony\Component\HttpFoundation\Response if ($show_results === 'posts') { - $text_only_message = $message = $row['article_body']; - if ($row['bbcode_uid']) + if (!empty($row['article_markdown'])) { - $text_only_message = str_replace('[*:' . $row['bbcode_uid'] . ']', '⋅ ', $text_only_message); - - // no BBCode in text only message - strip_bbcode($text_only_message, $row['bbcode_uid']); - $row['article_body'] = get_context($text_only_message, array_filter(explode('|', $hilit), 'strlen'), $return_chars); - $row['article_body'] = bbcode_nl2br($row['article_body']); - + $row['article_body'] = $this->article_text->search_display($row['article_body'], $row['bbcode_uid'], $return_chars); } - if ($return_chars == -1 || utf8_strlen($text_only_message) < ($return_chars + 3)) + else { - $row['article_body'] = $this->article_text->display($message, $row['bbcode_uid'], !empty($row['article_markdown']), false); + $text_only_message = $message = $row['article_body']; + if ($row['bbcode_uid']) + { + $text_only_message = str_replace('[*:' . $row['bbcode_uid'] . ']', '⋅ ', $text_only_message); + + // no BBCode in text only message + strip_bbcode($text_only_message, $row['bbcode_uid']); + $row['article_body'] = get_context($text_only_message, array_filter(explode('|', $hilit), 'strlen'), $return_chars); + $row['article_body'] = bbcode_nl2br($row['article_body']); + + } + if ($return_chars == -1 || utf8_strlen($text_only_message) < ($return_chars + 3)) + { + $row['article_body'] = $this->article_text->display($message, $row['bbcode_uid'], !empty($row['article_markdown']), false); + } + } if ($hilit) diff --git a/docs/images/screenshots/01-flat-index.png b/docs/images/screenshots/01-flat-index.png index 64f8c0f..c2d63fd 100644 Binary files a/docs/images/screenshots/01-flat-index.png and b/docs/images/screenshots/01-flat-index.png differ diff --git a/docs/images/screenshots/02-article.png b/docs/images/screenshots/02-article.png index f67d091..303a047 100644 Binary files a/docs/images/screenshots/02-article.png and b/docs/images/screenshots/02-article.png differ diff --git a/docs/images/screenshots/03-manage-your-articles.png b/docs/images/screenshots/03-manage-your-articles.png index ed50b94..2def352 100644 Binary files a/docs/images/screenshots/03-manage-your-articles.png and b/docs/images/screenshots/03-manage-your-articles.png differ diff --git a/docs/images/screenshots/04-add-article.png b/docs/images/screenshots/04-add-article.png index 0302228..d6b9231 100644 Binary files a/docs/images/screenshots/04-add-article.png and b/docs/images/screenshots/04-add-article.png differ diff --git a/docs/images/screenshots/05-approval-queue.png b/docs/images/screenshots/05-approval-queue.png index db2b91c..c611aea 100644 Binary files a/docs/images/screenshots/05-approval-queue.png and b/docs/images/screenshots/05-approval-queue.png differ diff --git a/docs/images/screenshots/06-search-results.png b/docs/images/screenshots/06-search-results.png index 47897cf..dd7fe51 100644 Binary files a/docs/images/screenshots/06-search-results.png and b/docs/images/screenshots/06-search-results.png differ diff --git a/docs/images/screenshots/07-acp-configuration.png b/docs/images/screenshots/07-acp-configuration.png index c432f47..8aa02c6 100644 Binary files a/docs/images/screenshots/07-acp-configuration.png and b/docs/images/screenshots/07-acp-configuration.png differ diff --git a/docs/images/screenshots/08-category-tree.png b/docs/images/screenshots/08-category-tree.png index eee3b71..ba1de4b 100644 Binary files a/docs/images/screenshots/08-category-tree.png and b/docs/images/screenshots/08-category-tree.png differ diff --git a/docs/images/screenshots/09-board-search-kb-block.png b/docs/images/screenshots/09-board-search-kb-block.png index 3b7343a..ba3db23 100644 Binary files a/docs/images/screenshots/09-board-search-kb-block.png and b/docs/images/screenshots/09-board-search-kb-block.png differ diff --git a/docs/images/screenshots/10-advanced-search-option.png b/docs/images/screenshots/10-advanced-search-option.png index 0e0854f..5b29d48 100644 Binary files a/docs/images/screenshots/10-advanced-search-option.png and b/docs/images/screenshots/10-advanced-search-option.png differ diff --git a/docs/images/screenshots/11-acp-search-settings.png b/docs/images/screenshots/11-acp-search-settings.png index 2105c79..ba0409d 100644 Binary files a/docs/images/screenshots/11-acp-search-settings.png and b/docs/images/screenshots/11-acp-search-settings.png differ diff --git a/docs/images/screenshots/12-recent-changes.png b/docs/images/screenshots/12-recent-changes.png index de26e67..d146245 100644 Binary files a/docs/images/screenshots/12-recent-changes.png and b/docs/images/screenshots/12-recent-changes.png differ diff --git a/docs/images/screenshots/13-submit-redirect.png b/docs/images/screenshots/13-submit-redirect.png new file mode 100644 index 0000000..22a17b3 Binary files /dev/null and b/docs/images/screenshots/13-submit-redirect.png differ diff --git a/docs/images/screenshots/14-change-author.png b/docs/images/screenshots/14-change-author.png new file mode 100644 index 0000000..08115f7 Binary files /dev/null and b/docs/images/screenshots/14-change-author.png differ diff --git a/docs/images/screenshots/15-private-draft-notice.png b/docs/images/screenshots/15-private-draft-notice.png new file mode 100644 index 0000000..28e4d11 Binary files /dev/null and b/docs/images/screenshots/15-private-draft-notice.png differ diff --git a/docs/images/screenshots/16-markdown-search.png b/docs/images/screenshots/16-markdown-search.png new file mode 100644 index 0000000..84b150b Binary files /dev/null and b/docs/images/screenshots/16-markdown-search.png differ diff --git a/docs/images/screenshots/README.md b/docs/images/screenshots/README.md index def6ac4..4455ba2 100644 --- a/docs/images/screenshots/README.md +++ b/docs/images/screenshots/README.md @@ -1,12 +1,17 @@ # Knowledge Base screenshots -Taken with Playwright by `phpbb-test-board/screenshots.py` in [phpbb-release-tools](https://github.com/phpbbmodders/phpbb-release-tools) on a throwaway phpBB 3.3 board (prosilver, SQLite) filled with example articles and forum questions, as an administrator. The flat article index is the default; the category tree needs **Hierarchical category browsing** switched on under **Extensions > Knowledge Base > Configuration**. - -A change that alters a page shown here means retaking them, with the extension's own entry in that script: - -```bash -phpbb-release-tools/phpbb-test-board/screenshots.py path/to/knowledgebase -o path/to/knowledgebase/docs/images/screenshots --seed path/to/seed-forum/bin/seed-standard-fixtures.php -``` +Captured with Playwright from the real Knowledge Base checkout mounted by +seed-forum on a disposable phpBB 3.3.19 board (prosilver, SQLite). The shared +`seed-kb-fixtures.php` supplies the articles, drafts, revisions, teams and +scoped author-transfer moderator. Member pages use `kb_author1`; the other +pages use the fixture administrator. + +The reusable capture script is `tests/browser/kb_screenshots.js`, run through +the Playwright skill runner with explicit `KB_TEST_URL`, `KB_TEST_BOARD_ROOT`, +`KB_ARTIFACT_DIR`, and `KB_SCREENSHOT_CONFIG_HELPER` variables. The companion +`tests/browser/kb_screenshot_config.php` temporarily switches hierarchical +browsing on for the category-tree capture and restores its previous setting. +The current fixture is the seed-forum board at `http://localhost:8102`. The two ACP pages are taken in full, so a setting further down the page is not cut off. @@ -14,71 +19,95 @@ The two ACP pages are taken in full, so a setting further down the page is not c Every article in one list, with a dropdown to narrow it down by category. -![The flat article index](01-flat-index.png) +The flat article index ## Hierarchical category browsing The category tree with its subcategories, article counts and the last article of each. -![The category tree](08-category-tree.png) +The category tree ## An article -The article with its status, details, a copyable link and BBCode, and the tab that reads "Reading Article #1". +The article with its status, details, a copyable link and BBCode, and the tab that reads "Reading Article #1", with the private-draft indicator visible to the administrator. -![An article](02-article.png) +An article ## Manage your articles A member's own articles and drafts. -![Manage Your Articles](03-manage-your-articles.png) +Manage Your Articles ## Recent changes The **Recent changes** tab: the live articles, newest first, with a **New** or **Updated** badge, the category, the author and when the article last changed. Under the heading are the RSS and Atom feeds of the same list. A browser or feed reader also finds them from the page header, and the ACP says which formats are offered. -![The Recent changes tab](12-recent-changes.png) +The Recent changes tab ## Add an article The form for a new article. -![The add article form](04-add-article.png) +The add article form ## The approval queue The Administration tab: new articles and revisions waiting for review, with the actions available to a moderator. -![The approval queue](05-approval-queue.png) +The approval queue ## Search Results of a search on the Knowledge Base's own search page. -![Search results](06-search-results.png) +Search results ## ACP configuration **Extensions > Knowledge Base > Configuration**, the whole page: the announcement and changelog forums, hierarchical browsing, the alternative stylesheet, the **Recent changes feed** (RSS, Atom or both), the number of articles per page, the description length and the sorting order, then the attachment settings, the review teams and the contact group. -![The configuration page in the ACP](07-acp-configuration.png) +The configuration page in the ACP ## Knowledge Base articles in the board's search A search in phpBB's own search for `backup`. The **Knowledge Base (4)** block sits above the forum results and lists the best matches with their category, a snippet with the word marked and a link to all of them; the forum post that asks about backups is the first of the forum results below it. Switch the block off, or set how many articles it lists, in the ACP. -![The board search with a Knowledge Base block above a forum post](09-board-search-kb-block.png) +The board search with a Knowledge Base block above a forum post ## Also search the Knowledge Base The advanced search has an **Also search the Knowledge Base** option, ticked by default. -![The advanced search with the Also search the Knowledge Base option](10-advanced-search-option.png) +The advanced search with the Also search the Knowledge Base option ## ACP search settings **Extensions > Knowledge Base > Search**: under **General search settings**, whether the board search shows the Knowledge Base block and how many articles it lists, beside the results per page for the Knowledge Base's own search; then the search backend and each backend's index. -![The Knowledge Base search settings in the ACP](11-acp-search-settings.png) +The Knowledge Base search settings in the ACP + + +## Submit a redirect + +The redirect form, with its visible Knowledge Base » Submit a Redirect heading. + +Submit a redirect + +## Change author + +Transfer the primary author to an active member or configured KB team while retaining the pending revision. + +Change author + +## Private draft Notice + +A blocked transfer appears in the extension's Notice tab, with the board styling and navigation intact. + +Private draft Notice + +## Rendered Markdown search + +Markdown headings, lists and emphasis render in a shortened search excerpt rather than appearing as source syntax. +Rendered Markdown search diff --git a/docs/testing.md b/docs/testing.md index 6395c09..97072be 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1357,3 +1357,34 @@ a guest getting 403 until guests may read and then both feeds and the page, a me `kb_feed_test.php` (the wiki's feed cases: formats offered, valid settings, both documents well formed with values escaped once, an empty feed) and `kb_recent_changes_test.php` (the migration; 403 without running a query, 404 for a format not offered, the feed built from given rows with its headers and New or Updated summaries, the query's live-only conditions and ordering, and the routes, service, tab, header template, ACP and language strings being wired in) are new; the style layout test now expects the extra tab. The suite passes with 485 tests (17 skipped: database cases without the MySQL and PostgreSQL fixtures, which this change does not touch). + + +## Primary author transfers + +The author-transfer tests cover permission checks, CSRF rejection, private +article and edit drafts, stale ownership, member/team transfers, co-author +handling, audit logging and rollback, and search-result cache invalidation. +Run `kb_author_change_database_test.php` with the disposable database ports +above to exercise the actual MySQL and PostgreSQL transfer SQL and permission +migration/rollback. + +The browser check uses the standard seed-forum fixtures on a dedicated SQLite +board whose directory is named `kb-author-change-*` under `seeded-board/`. +It temporarily grants the `forum_moderator` account transfer permission for +one category, prepares a pending revision and co-authors, and restores those +fixtures after the checks. The audit entries remain as test evidence. + +```bash +KB_TEST_URL=http://localhost:8100 \ +KB_TEST_BOARD_ROOT=/path/to/seeded-board/kb-author-change-33 \ +node /path/to/playwright-skill/run.js tests/browser/kb_author_change.js +``` + +`KB_ARTIFACT_DIR` optionally selects a directory for a full-page form screenshot. +Use only disposable fixture boards: these checks change article ownership +through real browser sessions before restoring it. + +The canonical seed-forum `bin/seed-kb-fixtures.php` supplies the author-transfer +moderator, co-authors and pending revision. Build a fresh board with the updated +script before running the browser suite. Its Python helper only snapshots and +restores the fixture; it does not create articles or grant permissions. diff --git a/docs/update/2026-10-18/01-change-author.png b/docs/update/2026-10-18/01-change-author.png new file mode 100644 index 0000000..08115f7 Binary files /dev/null and b/docs/update/2026-10-18/01-change-author.png differ diff --git a/docs/update/2026-10-18/02-queue-statuses.png b/docs/update/2026-10-18/02-queue-statuses.png new file mode 100644 index 0000000..c611aea Binary files /dev/null and b/docs/update/2026-10-18/02-queue-statuses.png differ diff --git a/docs/update/2026-10-18/03-markdown-search.png b/docs/update/2026-10-18/03-markdown-search.png new file mode 100644 index 0000000..84b150b Binary files /dev/null and b/docs/update/2026-10-18/03-markdown-search.png differ diff --git a/inc/author_change.php b/inc/author_change.php new file mode 100644 index 0000000..28bf62a --- /dev/null +++ b/inc/author_change.php @@ -0,0 +1,99 @@ +db->sql_transaction('begin'); + try + { + $sql = 'UPDATE ' . $this->articles_table . ' SET ' . $this->db->sql_build_array('UPDATE', [ + 'author_id' => $author_id, 'author_type' => $author_type, 'author' => $author_name, + ]) . ' + WHERE article_id = ' . $article_id . ' + AND author_id = ' . (int) $article['author_id'] . ' + AND author_type = ' . (int) $article['author_type'] . ' + AND status = ' . (int) $article['status'] . ' + AND NOT EXISTS (SELECT article_id FROM ' . $this->draft_edits_table . ' + WHERE article_id = ' . $article_id . ')'; + $this->db->sql_query($sql); + if (!$this->db->sql_affectedrows()) + { + throw new \RuntimeException('KB_CHANGE_AUTHOR_CONFLICT'); + } + + // The new primary author must not also be listed as a co-author. + $this->db->sql_query('DELETE FROM ' . $this->co_authors_table . ' + WHERE article_id = ' . $article_id . ' + AND author_type = ' . $author_type . ' + AND author_ref_id = ' . $author_id); + $log_id = $this->log->add('admin', (int) $this->user->data['user_id'], $this->user->ip, + 'LOG_LIBRARY_CHANGED_AUTHOR', time(), [ + $article['article_title'], + $article['author'] . ' (' . (int) $article['author_type'] . ':' . (int) $article['author_id'] . ')', + $author_name . ' (' . $author_type . ':' . $author_id . ')', + ]); + if (!$log_id) + { + throw new \RuntimeException('KB_CHANGE_AUTHOR_LOG_FAILED'); + } + $this->db->sql_transaction('commit'); + } + catch (\Throwable $error) + { + $this->db->sql_transaction('rollback'); + throw $error; + } + + // Author filters are cached separately from the full-text word index. + // Invalidate KB results only; their word matches need no rebuilding. + $result = $this->db->sql_query('SELECT search_key FROM ' . $this->search_results_table); + while ($row = $this->db->sql_fetchrow($result)) + { + $this->cache->destroy('_kb_search_results_' . $row['search_key']); + } + $this->db->sql_freeresult($result); + return true; + } +} diff --git a/inc/functions_kb.php b/inc/functions_kb.php index 65d92d8..b700018 100644 --- a/inc/functions_kb.php +++ b/inc/functions_kb.php @@ -1699,10 +1699,27 @@ public function delete_article_revision(int $article_id): void $this->db->sql_query($sql); } - /** - * @param int $article_id - * @return bool - */ + /** Return pending revision and private draft existence flags for a page of articles. */ + public function get_article_edit_flags_bulk(array $article_ids): array + { + $flags = []; + $article_ids = array_values(array_unique(array_map('intval', $article_ids))); + if (!$article_ids) + { + return $flags; + } + foreach (['revision' => $this->revisions_table, 'draft' => $this->draft_edits_table] as $flag => $table) + { + $result = $this->db->sql_query('SELECT article_id FROM ' . $table . ' WHERE ' . $this->db->sql_in_set('article_id', $article_ids)); + while ($row = $this->db->sql_fetchrow($result)) + { + $flags[(int) $row['article_id']][$flag] = true; + } + $this->db->sql_freeresult($result); + } + return $flags; + } + public function article_has_draft_edit(int $article_id): bool { return (bool) $this->get_article_draft_edit($article_id); diff --git a/language/en/acp_knowledgebase.php b/language/en/acp_knowledgebase.php index 1dd2277..cc508bc 100644 --- a/language/en/acp_knowledgebase.php +++ b/language/en/acp_knowledgebase.php @@ -158,6 +158,7 @@ 'kb_u_delete' => 'Can delete own articles', 'kb_u_add_noapprove' => 'Can add / modify articles without prior approval', // Moderator Permissions +'kb_m_change_author' => 'Can change the primary author of submitted articles', 'kb_m_edit' => 'Can edit articles', 'kb_m_delete' => 'Can delete articles', 'kb_m_approve' => 'Can approve articles', diff --git a/language/en/knowledgebase_lng.php b/language/en/knowledgebase_lng.php index deeb942..b35ac84 100644 --- a/language/en/knowledgebase_lng.php +++ b/language/en/knowledgebase_lng.php @@ -18,10 +18,21 @@ } $lang = array_merge($lang, [ + 'KB_CHANGE_AUTHOR' => 'Change author', + 'KB_CHANGE_AUTHOR_TYPE' => 'New author type', + 'KB_CHANGE_AUTHOR_SELECT_TEAM' => 'Select a team', + 'KB_CHANGE_AUTHOR_TEAM' => 'Knowledge Base team', + 'KB_CHANGE_AUTHOR_EXPLAIN' => 'Choose a member or a Knowledge Base team as the new primary author. Use the field for the selected author type. Co-authors, article content, and submitted revisions are retained. This replaces the primary author; co-author and team permissions still apply.', + 'KB_CHANGE_AUTHOR_LOG_FAILED' => 'The author could not be changed because the audit log could not be written.', + 'KB_CHANGE_AUTHOR_INVALID' => 'Choose an active member or a configured Knowledge Base team.', + 'KB_CHANGE_AUTHOR_PRIVATE_DRAFT' => 'Authorship cannot be transferred while a private draft or private draft edit exists. Ask its author to submit or discard it first.', + 'KB_CHANGE_AUTHOR_CONFLICT' => 'The author or article status changed, or a private draft edit was saved. Reload the form before trying again.', + 'LOG_LIBRARY_CHANGED_AUTHOR' => 'Changed the author of article “%1$s”
From %2$s to %3$s', + 'ALPHABET' => 'A-B-C-D-E-F-G-H-I-J-K-L-M-N-O-P-Q-R-S-T-U-V-W-X-Y-Z', 'ALPHABET_NAV' => 'Browse by letter:', 'KB_ALPHABET_LETTER' => 'Articles starting with %s', - 'ADD_ARTICLE' => 'Add article', + 'ADD_ARTICLE' => 'Add Article', 'APPROVE' => 'Approve', 'ARTICLE' => 'Article', 'ARTICLES' => 'Articles', @@ -116,6 +127,7 @@ 'KB_REVISION_PREVIEW' => 'Pending revision (not yet live)', 'KB_REVIEW_REVISION' => 'Review revision', 'KB_SKIP_MESSAGE' => 'Don’t message the author', + 'KB_HAS_DRAFT' => 'Has draft', 'KB_STATUS' => 'Status', 'KB_STATUS_ACTIVE' => 'Active', 'KB_STATUS_ACTIVE_EXPLAIN' => 'Your article is visible to the public.', diff --git a/migrations/version_2_0_30.php b/migrations/version_2_0_30.php new file mode 100644 index 0000000..1c75cb1 --- /dev/null +++ b/migrations/version_2_0_30.php @@ -0,0 +1,63 @@ +db->sql_query("SELECT auth_option_id FROM " . $this->table_prefix . "kb_options WHERE auth_option = 'kb_m_change_author'"); + $exists = $this->db->sql_fetchfield('auth_option_id'); + $this->db->sql_freeresult($result); + if (!$exists) + { + $this->db->sql_query('INSERT INTO ' . $this->table_prefix . 'kb_options ' . $this->db->sql_build_array('INSERT', [ + 'auth_option' => 'kb_m_change_author', 'is_global' => 0, 'is_local' => 1, + ])); + } + } + + public function revert_data() + { + return [ + ['custom', [[$this, 'remove_permission']]], + ['config.update', ['kb_version', '2.0.29']], + ]; + } + + public function remove_permission(): void + { + $result = $this->db->sql_query("SELECT auth_option_id FROM " . $this->table_prefix . "kb_options WHERE auth_option = 'kb_m_change_author'"); + $id = (int) $this->db->sql_fetchfield('auth_option_id'); + $this->db->sql_freeresult($result); + if (!$id) + { + return; + } + foreach (['kb_users', 'kb_groups', 'kb_options'] as $table) + { + $this->db->sql_query('DELETE FROM ' . $this->table_prefix . $table . ' WHERE auth_option_id = ' . $id); + } + } +} diff --git a/styles/all/template/kb_article_body.html b/styles/all/template/kb_article_body.html index 1002b4c..b99f386 100644 --- a/styles/all/template/kb_article_body.html +++ b/styles/all/template/kb_article_body.html @@ -20,13 +20,8 @@

{{ lang('LIBRARY') }}

{% if FIELD_DISPLAY %}{{ FIELD_DISPLAY }}{% endif %}
- {% if S_STATUS_NEW %}{{ lang('KB_STATUS_NEW') }} - {% elseif S_STATUS_REVIEWED %}{{ lang('KB_STATUS_REVIEWED') }} - {% elseif S_STATUS_ACTIVE %}{{ lang('KB_STATUS_ACTIVE') }}{% if S_HAS_REVISION %} {{ lang('KB_STATUS_REVISION') }}{% endif %} - {% elseif S_STATUS_INACTIVE %}{{ lang('KB_STATUS_INACTIVE') }}{% if S_HAS_REVISION %} {{ lang('KB_STATUS_REVISION') }}{% endif %} - {% elseif S_STATUS_DRAFT %}{{ lang('KB_STATUS_DRAFT') }} - {% endif %} - {% if S_HAS_DRAFT_EDIT %} {{ lang('KB_DRAFT_EDIT_PENDING') }}{% endif %} + +

{{ ARTICLE_TITLE }}

{{ lang('ARTICLE_DESCRIPTION') }}{{ lang('COLON') }} {{ ARTICLE_DESCRIPTION }}

@@ -38,6 +33,16 @@

{{ ARTICLE_TITLE }}

{{ ARTICLE_CATEGORY }} {% endif %}

+

{{ lang('KB_STATUS') }}{{ lang('COLON') }} + {% if S_STATUS_NEW %}{{ lang('KB_STATUS_NEW') }} + {% elseif S_STATUS_REVIEWED %}{{ lang('KB_STATUS_REVIEWED') }} + {% elseif S_STATUS_ACTIVE %}{{ lang('KB_STATUS_ACTIVE') }}{% if S_HAS_REVISION %} {{ lang('KB_STATUS_REVISION') }}{% endif %} + {% elseif S_STATUS_INACTIVE %}{{ lang('KB_STATUS_INACTIVE') }}{% if S_HAS_REVISION %} {{ lang('KB_STATUS_REVISION') }}{% endif %} + {% elseif S_STATUS_DRAFT %}{{ lang('KB_STATUS_DRAFT') }} + {% endif %} + {% if S_HAS_DRAFT_EDIT %} {{ lang('KB_HAS_DRAFT') }}{% endif %} +

+

{{ lang('LINK_TO_ARTICLE') }}{{ lang('COLON') }} {{ lang('COPY_TO_BUFFER') }}

@@ -63,6 +68,10 @@

{{ ARTICLE_TITLE }}

{{ lang('EDIT') }} {% endif %} + {% if U_CHANGE_AUTHOR %} +
  • + {{ lang('KB_CHANGE_AUTHOR') }}
  • + {% endif %} {% if S_CAN_DELETE %}
  • diff --git a/styles/all/template/kb_cat_body.html b/styles/all/template/kb_cat_body.html index fa02a91..4237463 100644 --- a/styles/all/template/kb_cat_body.html +++ b/styles/all/template/kb_cat_body.html @@ -105,6 +105,7 @@

    {{ CATEGORY }}

    {% if art_row.S_IS_REDIRECT %}{% endif %} {% if art_row.ARTICLE_DESCRIPTION %}
    {{ art_row.ARTICLE_DESCRIPTION }}{% endif %} +
    {% INCLUDE '@phpbbmodders_knowledgebase/kb_list_status.html' %} {{ art_row.ART_VIEWS }} {{ art_row.ARTICLE_AUTHOR }}{% if art_row.CO_AUTHORS %}, {{ art_row.CO_AUTHORS }}{% endif %}
    {{ art_row.ARTICLE_DATE }} diff --git a/styles/all/template/kb_change_author_body.html b/styles/all/template/kb_change_author_body.html new file mode 100644 index 0000000..8aed7c9 --- /dev/null +++ b/styles/all/template/kb_change_author_body.html @@ -0,0 +1,35 @@ +{% INCLUDE 'overall_header.html' %} +

    {{ lang('LIBRARY') }}

    +{% INCLUDE '@phpbbmodders_knowledgebase/kb_nav_tabs.html' %} +

    {{ lang('KB_CHANGE_AUTHOR') }}

    +

    {{ ARTICLE_TITLE }}

    +
    +
    +
    + {% if ERROR %}

    {{ ERROR }}

    {% endif %} +

    {{ lang('KB_CHANGE_AUTHOR_EXPLAIN') }}

    +

    {{ lang('KB_WRITTEN_BY') }}{{ lang('COLON') }} {{ ARTICLE_AUTHOR }}

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + {{ lang('CANCEL') }} + {{ S_FORM_TOKEN }} +
    +
    +{% INCLUDE 'overall_footer.html' %} diff --git a/styles/all/template/kb_list_status.html b/styles/all/template/kb_list_status.html new file mode 100644 index 0000000..8663eb9 --- /dev/null +++ b/styles/all/template/kb_list_status.html @@ -0,0 +1,8 @@ +{{ lang('KB_STATUS') }}{{ lang('COLON') }} +{% if art_row.STATUS == 0 %}{{ lang('KB_STATUS_NEW') }} +{% elseif art_row.STATUS == 1 %}{{ lang('KB_STATUS_REVIEWED') }} +{% elseif art_row.STATUS == 2 %}{{ lang('KB_STATUS_ACTIVE') }} +{% elseif art_row.STATUS == 3 %}{{ lang('KB_STATUS_INACTIVE') }} +{% elseif art_row.STATUS == 4 %}{{ lang('KB_STATUS_DRAFT') }}{% endif %} +{% if art_row.S_HAS_REVISION %} {{ lang('KB_STATUS_REVISION') }}{% endif %} +{% if art_row.S_HAS_DRAFT_EDIT %} {{ lang('KB_HAS_DRAFT') }}{% endif %} diff --git a/styles/all/template/kb_my_articles_body.html b/styles/all/template/kb_my_articles_body.html index 73165f7..15a54ba 100644 --- a/styles/all/template/kb_my_articles_body.html +++ b/styles/all/template/kb_my_articles_body.html @@ -45,7 +45,7 @@

    {{ lang('LIBRARY') }} » {{ l {{ art_row.ARTICLE_AUTHOR }}{% if art_row.CO_AUTHORS %}, {{ art_row.CO_AUTHORS }}{% endif %} {% if art_row.S_HAS_REVISION %}{{ lang('KB_STATUS_REVISION') }}
    {% endif %} - {% if art_row.S_HAS_DRAFT_EDIT %}{{ lang('KB_DRAFT_EDIT_PENDING') }}{% endif %} + {% if art_row.S_HAS_DRAFT_EDIT %}{{ lang('KB_HAS_DRAFT') }}{% endif %} {{ art_row.ARTICLE_DATE }}{% if art_row.EDIT_DATE %}
    {{ art_row.EDIT_DATE }}{% endif %} diff --git a/styles/all/template/kb_post_body.html b/styles/all/template/kb_post_body.html index 8fe3678..b05e95c 100644 --- a/styles/all/template/kb_post_body.html +++ b/styles/all/template/kb_post_body.html @@ -1,5 +1,5 @@ {% INCLUDE 'overall_header.html' %} -

    {{ CATEGORY_NAME }}

    +

    {% if S_EDIT_POST %}{{ CATEGORY_NAME }}{% else %}{{ lang('LIBRARY') }} » {{ lang('POST_A') }}{% endif %}

    {% INCLUDE '@phpbbmodders_knowledgebase/kb_nav_tabs.html' %}
    diff --git a/styles/all/template/kb_queue_body.html b/styles/all/template/kb_queue_body.html index fff6284..d72655c 100644 --- a/styles/all/template/kb_queue_body.html +++ b/styles/all/template/kb_queue_body.html @@ -35,7 +35,7 @@

    {{ lang('LIBRARY') }} » {{ l {% for art_row in loops.art_row %} - {{ art_row.ARTICLE_TITLE }}{% if art_row.CATEGORY %}
    {{ art_row.CATEGORY }}{% endif %} + {{ art_row.ARTICLE_TITLE }}{% if art_row.CATEGORY %}
    {{ art_row.CATEGORY }}{% endif %}
    {% INCLUDE '@phpbbmodders_knowledgebase/kb_list_status.html' %} {{ art_row.ARTICLE_AUTHOR }}
    {{ art_row.ARTICLE_DATE }} {% if art_row.U_TOPIC %} diff --git a/styles/all/theme/phpbbmodders_knowledgebase.css b/styles/all/theme/phpbbmodders_knowledgebase.css index 64eaa59..a001f57 100644 --- a/styles/all/theme/phpbbmodders_knowledgebase.css +++ b/styles/all/theme/phpbbmodders_knowledgebase.css @@ -79,6 +79,19 @@ dd.action { margin: 0; } +/* Separate article links from the author fields, including the final Topics row. */ +.kb-meta ~ .postbody .kb_codebox { + clear: both; + margin-top: 12px; +} + +/* phpBB 4.0 code elements are inline; contain long article URLs on mobile. */ +.kb_codebox code { + display: block; + max-width: 100%; + overflow-x: auto; +} + .kb_codebox input { border: none; color: #ffffff; @@ -376,3 +389,20 @@ table.kb-index-table .kb-index-author { padding: 0; border-radius: 3px 3px 0 0; } + +/* Keep title metadata together without clearing the floating author sidebar. */ +.kb-article-heading { + display: flow-root; + margin-right: 272px; +} + +.kb-meta ~ .postbody .kb-article-heading h3 { + float: none; + margin-right: 0; +} + +@media (max-width: 700px) { + .kb-article-heading { + margin-right: 0; + } +} diff --git a/tests/browser/kb_author_change.js b/tests/browser/kb_author_change.js new file mode 100644 index 0000000..a59f51e --- /dev/null +++ b/tests/browser/kb_author_change.js @@ -0,0 +1,140 @@ +// Run only on a disposable seed-forum board prepared for author-change checks. +// KB_TEST_URL and KB_TEST_BOARD_ROOT are explicit; fixture passwords are public. +const { chromium } = require('playwright'); +const { expect } = require('playwright/test'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); +const fs = require('node:fs'); +const base = process.env.KB_TEST_URL; +const root = process.env.KB_TEST_BOARD_ROOT; +if (!base || !root || !root.includes('/seeded-board/kb-author-change-')) { + throw new Error('Set the disposable author-change board URL and root'); +} +const fixture = path.join(__dirname, 'kb_author_change_fixture.py'); +function data(operation) { + return JSON.parse(execFileSync('python3', [fixture, root, operation], { encoding: 'utf8' })); +} +async function ready(page) { + await page.waitForFunction(() => { + const input = document.querySelector('input[name="creation_time"]'); + return !input || Math.floor(Date.now() / 1000) > Number(input.value) + 1; + }); +} +(async () => { + const before = data('prepare'); + const browser = await chromium.launch({ headless: !process.env.DISPLAY }); + const contexts = []; + let checks = 0; + const errors = []; + const url = id => `${base}/knowledgebase/change_author?k=${id}`; + async function login(name) { + const context = await browser.newContext(); + contexts.push(context); + const page = await context.newPage(); + page.on('pageerror', error => errors.push(error.message)); + await page.goto(`${base}/ucp.php?mode=login`); + await page.locator('#username').fill(name); + await page.locator('#password').fill('KbTest1234!'); + await ready(page); + await page.locator('[name="login"][type="submit"]').click(); + await page.waitForURL(value => !value.search.includes('mode=login'), { timeout: 10000 }).catch(async error => { console.error(name, await page.locator('body').innerText()); throw error; }); + return page; + } + async function status(page, id, expected) { + expect((await page.goto(url(id))).status()).toBe(expected); + checks++; + } + async function transfer(page, type, value) { + await page.goto(url(2)); + await page.locator('#author_type').selectOption(String(type)); + if (type === 0) await page.locator('#username').fill(value); + else await page.locator('#group_id').selectOption(String(value)); + await ready(page); + await page.getByRole('button', { name: 'Change author', exact: true }).click(); + await expect(page).toHaveURL(/knowledgebase\/article\?k=2/); + checks++; + } + try { + const guest = await browser.newPage(); + await status(guest, 2, 403); + const reader = await login('kb_reader'); + await status(reader, 2, 403); + const original = await login('kb_author1'); + await status(original, 2, 403); + const moderator = await login('kb_transfer_moderator'); + await status(moderator, 2, 200); + await status(moderator, 4, 403); + const admin = await login('admin'); + await status(admin, 11, 404); + await status(admin, 1, 409); + await expect(admin.locator('.kb-notice')).toContainText('Authorship cannot be transferred'); + await expect(admin.locator('.kb-tabs')).toContainText('Notice'); + checks++; + await admin.goto(`${base}/knowledgebase/article?k=1`); + await expect(admin.locator('.kb-article-status')).toContainText('Has draft'); + await reader.goto(`${base}/knowledgebase/article?k=1`); + await expect(reader.locator('.kb-article-status')).not.toContainText('Has draft'); + checks++; + await admin.goto(`${base}/knowledgebase/category?id=${before.article.article_category_id}`); + await expect(admin.locator('tr[id="2"] .kb-article-status')).toContainText('Revision pending'); + checks++; + + await admin.goto(url(2)); + await expect(admin.getByRole('heading', { name: 'Change author', exact: true })).toBeVisible(); + checks++; + const form = await admin.locator('form[action*="change_author"]').evaluate(form => Object.fromEntries(new FormData(form))); + const forged = await admin.request.post(url(2), { form: { ...form, submit: 'Change author', username: 'kb_author2', form_token: 'invalid' } }); + expect(await forged.text()).toContain('The submitted form was invalid'); + expect(data('snapshot').article.author_id).toBe(before.article.author_id); + checks++; + await admin.locator('#username').fill('nonexistent_fixture_account'); + await ready(admin); + await admin.getByRole('button', { name: 'Change author', exact: true }).click(); + await expect(admin.getByText('Choose an active member or a configured Knowledge Base team.', { exact: true })).toBeVisible(); + checks++; + await admin.goto(url(2)); + await admin.locator('#username').fill('kb_author2'); + await admin.locator('[name="old_author_id"]').evaluate(input => input.value = '999999'); + await ready(admin); + await admin.getByRole('button', { name: 'Change author', exact: true }).click(); + await expect(admin.getByText(/The author or article status changed/)).toBeVisible(); + checks++; + await transfer(moderator, 0, 'kb_author2'); + let after = data('snapshot'); + expect(after.article.author_id).toBe(before.target_user); + expect(after.article.author_type).toBe(0); + expect(after.revisions).toEqual(before.revisions); + expect(after.coauthors).toEqual(before.coauthors.filter(row => !(row.author_type === 0 && row.author_ref_id === before.target_user))); + for (const key of Object.keys(before.article).filter(key => !['author_id', 'author_type', 'author', 'views'].includes(key))) { + expect(after.article[key], key).toEqual(before.article[key]); + } + checks++; + await original.goto(`${base}/knowledgebase/article?k=2`); + await expect(original.getByRole('link', { name: 'Edit', exact: true })).toHaveCount(0); + const newAuthor = await login('kb_author2'); + await newAuthor.goto(`${base}/knowledgebase/article?k=2`); + await expect(newAuthor.getByRole('link', { name: 'Edit', exact: true })).toBeVisible(); + checks++; + await transfer(admin, 1, before.team_id); + expect(data('snapshot').article.author_type).toBe(1); + checks++; + const output = process.env.KB_ARTIFACT_DIR; + if (output) { + fs.mkdirSync(output, { recursive: true }); + await admin.goto(url(2)); + await admin.screenshot({ path: path.join(output, 'change-author.png'), fullPage: true }); + } + await transfer(admin, 0, 'kb_author1'); + after = data('snapshot'); + expect(after.article.author_id).toBe(before.article.author_id); + expect(after.revisions).toEqual(before.revisions); + expect(after.logs).toBe(before.logs + 3); + expect(errors).toEqual([]); + checks++; + console.log(JSON.stringify({ base, checks, result: 'PASS' })); + } finally { + data('restore'); + for (const context of contexts) await context.close(); + await browser.close(); + } +})(); diff --git a/tests/browser/kb_author_change_fixture.py b/tests/browser/kb_author_change_fixture.py new file mode 100644 index 0000000..b01a8dd --- /dev/null +++ b/tests/browser/kb_author_change_fixture.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Prepare and restore author-change fixtures on a seeded SQLite board.""" + +import json +import pathlib +import sqlite3 +import sys + +root = pathlib.Path(sys.argv[1]).resolve() +operation = sys.argv[2] +if "/seeded-board/kb-author-change-" not in str(root): + raise SystemExit("Not a disposable author-change fixture board") +if not (root / ".seed-forum-seeds.json").is_file(): + raise SystemExit("Board must be built through seed-forum") +db = sqlite3.connect(root / "store/sqlite.db") +db.row_factory = sqlite3.Row +saved = root / "store/author-change-test-snapshot.json" + + +def rows(sql, parameters=()): + """Read rows as dictionaries for deterministic preservation checks.""" + return [dict(row) for row in db.execute(sql, parameters)] + + +def snapshot(): + """Read the article, its revision, co-authors, and audit-entry count.""" + return { + "article": rows("SELECT * FROM phpbb_kb_articles WHERE article_id=2")[ + 0 + ], + "revisions": rows( + "SELECT * FROM phpbb_kb_article_revisions WHERE article_id=2" + ), + "coauthors": rows( + ( + "SELECT * FROM phpbb_kb_article_co_authors WHERE article_id=2" + " ORDER BY author_type,author_ref_id" + ) + ), + "logs": db.execute( + ( + "SELECT COUNT(*) FROM phpbb_kb_log WHERE log_operation='LOG_L" + "IBRARY_CHANGED_AUTHOR'" + ) + ).fetchone()[0], + } + + +if operation == "prepare": + if saved.exists(): + raise SystemExit( + "Restore the previous fixture run before preparing again" + ) + original = snapshot() + moderator = db.execute( + "SELECT user_id FROM phpbb_users " + "WHERE username='kb_transfer_moderator'" + ).fetchone() + target = db.execute( + "SELECT user_id FROM phpbb_users WHERE username='kb_author2'" + ).fetchone()[0] + teams = [row["author_ref_id"] for row in original["coauthors"] + if row["author_type"] == 1] + if not moderator or not original["revisions"] or not teams: + raise SystemExit("Build the board with the updated KB seed script") + grant = db.execute( + "SELECT u.auth_setting FROM phpbb_kb_users u " + "JOIN phpbb_kb_options o ON o.auth_option_id=u.auth_option_id " + "WHERE u.user_id=? AND u.category_id=? " + "AND o.auth_option='kb_m_change_author'", + (moderator[0], original["article"]["article_category_id"]), + ).fetchone() + if not grant or grant[0] != 1: + raise SystemExit("The seed must grant scoped author-transfer access") + saved.write_text(json.dumps(original), encoding="utf-8") + result = original.copy() + result.update(target_user=target, team_id=teams[0]) +elif operation == "snapshot": + result = snapshot() +elif operation == "restore": + original = json.loads(saved.read_text(encoding="utf-8")) + with db: + article = original["article"] + db.execute( + ( + "UPDATE phpbb_kb_articles SET author_id=?,author_type=?,autho" + "r=? WHERE article_id=2" + ), + (article["author_id"], article["author_type"], article["author"]), + ) + db.execute( + "DELETE FROM phpbb_kb_article_co_authors WHERE article_id=2" + ) + for row in original["coauthors"]: + db.execute( + "INSERT INTO phpbb_kb_article_co_authors VALUES (?,?,?)", + tuple(row.values()), + ) + with db: + db.execute("DELETE FROM phpbb_kb_article_revisions WHERE article_id=2") + for revision in original["revisions"]: + columns = ",".join(revision) + placeholders = ",".join("?" for _ in revision) + db.execute( + f"INSERT INTO phpbb_kb_article_revisions ({columns}) " + f"VALUES ({placeholders})", + tuple(revision.values()), + ) + saved.unlink() + result = {"restored": True} +else: + raise SystemExit("Use prepare, snapshot, or restore") +print(json.dumps(result)) diff --git a/tests/browser/kb_layout_search.js b/tests/browser/kb_layout_search.js new file mode 100644 index 0000000..6ba9e1e --- /dev/null +++ b/tests/browser/kb_layout_search.js @@ -0,0 +1,43 @@ +const {chromium} = require('playwright'); +const {expect} = require('playwright/test'); +const fs = require('node:fs'); +const base = process.env.KB_TEST_URL; +if (!/^http:\/\/localhost:810[01]$/.test(base || '')) throw new Error('Use the disposable KB preview URL'); +(async()=>{ + const browser=await chromium.launch({headless:true}); + const page=await browser.newPage({viewport:{width:1280,height:900}}); + try { + await page.goto(`${base}/ucp.php?mode=login`); + await page.locator('#username').fill('admin'); + await page.locator('#password').fill('KbTest1234!'); + await page.waitForFunction(()=>Date.now()/1000>Number(document.querySelector('[name="creation_time"]').value)+2); + await page.locator('[name="login"][type="submit"]').click(); + await page.waitForURL(url=>!url.search.includes('mode=login')); + await page.goto(`${base}/knowledgebase/article?k=2`); + const heading=page.locator('.kb-article-heading'); + await expect(heading).toContainText('Description:'); + await expect(heading).toContainText('Category:'); + const titleBox=await heading.locator('h3').boundingBox(); + const descriptionBox=await heading.locator('p').first().boundingBox(); + expect(descriptionBox.y-titleBox.y-titleBox.height).toBeLessThan(25); + const out=process.env.KB_ARTIFACT_DIR; + fs.mkdirSync(out,{recursive:true}); + await page.screenshot({path:`${out}/article-layout.png`,fullPage:true}); + await page.setViewportSize({width:390,height:844}); + await expect(heading).toBeVisible(); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true); + await page.screenshot({path:`${out}/article-mobile.png`,fullPage:true}); + await page.setViewportSize({width:1280,height:900}); + await page.goto(`${base}/knowledgebase/queue`); + expect(await page.locator('.kb-article-status .kb-status-badge').count()).toBeGreaterThan(0); + await page.screenshot({path:`${out}/queue-statuses.png`,fullPage:true}); + await page.goto(`${base}/knowledgebase/search?keywords=clean&sf=articles&cid=0`); + await expect(page.locator('.content').filter({hasText:'Clean URL Troubleshooting'}).first()).toBeVisible(); + const results=page.locator('.content').filter({hasText:'Clean URL Troubleshooting'}).first(); + expect(await results.locator('h1,h2,h3').count()).toBeGreaterThan(0); + expect(await results.innerText()).not.toContain('## Before you begin'); + expect(await results.innerText()).not.toContain('# Clean URL Troubleshooting'); + await page.screenshot({path:`${out}/markdown-search.png`,fullPage:true}); + console.log(JSON.stringify({base,result:'PASS',checks:['metadata placement','mobile overflow','queue badges','rendered Markdown search']})); + } finally {await browser.close();} +})(); diff --git a/tests/browser/kb_screenshot_config.php b/tests/browser/kb_screenshot_config.php new file mode 100644 index 0000000..409975f --- /dev/null +++ b/tests/browser/kb_screenshot_config.php @@ -0,0 +1,6 @@ +set('kb_hierarchical_index', (int) $argv[2]); +echo $previous; diff --git a/tests/browser/kb_screenshots.js b/tests/browser/kb_screenshots.js new file mode 100644 index 0000000..8e6ebfb --- /dev/null +++ b/tests/browser/kb_screenshots.js @@ -0,0 +1,72 @@ +// Capture only from the disposable board created through seed-forum. +const {chromium}=require('playwright'); +const {expect}=require('playwright/test'); +const {execFileSync}=require('node:child_process'); +const fs=require('node:fs'); +const path=require('node:path'); +const base=process.env.KB_TEST_URL; +const root=process.env.KB_TEST_BOARD_ROOT; +const out=process.env.KB_ARTIFACT_DIR; +const configHelper=process.env.KB_SCREENSHOT_CONFIG_HELPER; +if(base!=='http://localhost:8102'||!root?.endsWith('/kb-author-change-seed-33')||!out||!configHelper)throw new Error('Use the explicit seed-forum screenshot fixture'); +function tree(value){return execFileSync('php',[configHelper,root,...(value===undefined?[]:[String(value)])],{encoding:'utf8'}).trim();} +(async()=>{ + const browser=await chromium.launch({headless:true}); + const previous=tree(); + fs.mkdirSync(out,{recursive:true}); + const manifest=[]; + async function login(name){ + const context=await browser.newContext({viewport:{width:1440,height:1000},locale:'en-US'}); + const page=await context.newPage(); + await page.goto(`${base}/ucp.php?mode=login`); + await page.locator('#username').fill(name); + await page.locator('#password').fill('KbTest1234!'); + await page.waitForFunction(()=>Date.now()/1000>Number(document.querySelector('[name="creation_time"]').value)+2); + await page.locator('[name="login"][type="submit"]').click(); + await page.waitForURL(u=>!u.search.includes('mode=login')); + return page; + } + async function shot(page,name,url,selector,notice=false){ + const response=await page.goto(`${base}/${url}`,{waitUntil:'networkidle'}); + if(!response||response.status()>=400&&!(notice&&response.status()===409))throw new Error(`${name}: HTTP ${response?.status()}`); + await expect(page.locator(selector).first()).toBeVisible(); + if(await page.locator('body').innerText().then(t=>/Fatal error:|General Error|Parse error:/.test(t)))throw new Error(`${name}: error page`); + await page.screenshot({path:path.join(out,name+'.png'),fullPage:true}); + manifest.push({name,url});console.log('Saved '+name); + } + try{ + const admin=await login('admin'); + const author=await login('kb_author1'); + if(!process.env.KB_SCREENSHOTS_ACP_ONLY){ + tree(0); + await shot(admin,'01-flat-index','knowledgebase','table.kb-index-table'); + await shot(admin,'02-article','knowledgebase/article?k=1','.kb-article-heading'); + await shot(author,'03-manage-your-articles','knowledgebase/my_articles','table'); + await shot(admin,'04-add-article','knowledgebase/posting?id=1','#postform'); + await shot(admin,'05-approval-queue','knowledgebase/queue','table'); + await shot(admin,'06-search-results','knowledgebase/search?keywords=backup','.content'); + await shot(admin,'09-board-search-kb-block','search.php?keywords=backup','#page-body'); + await shot(admin,'10-advanced-search-option','search.php','#kb_also'); + await shot(admin,'12-recent-changes','knowledgebase/recent_changes','#page-body'); + await shot(admin,'13-submit-redirect','knowledgebase/posting?id=1&mode=redirect','#postform'); + await shot(admin,'14-change-author','knowledgebase/change_author?k=2','form[action*="change_author"]'); + await shot(admin,'15-private-draft-notice','knowledgebase/change_author?k=1','.kb-notice',true); + await shot(admin,'16-markdown-search','knowledgebase/search?keywords=clean&sf=articles&cid=0','.content'); + tree(1); + await shot(admin,'08-category-tree','knowledgebase','#page-body'); + } + let sid=(await admin.context().cookies()).find(c=>c.name.endsWith('_sid')).value; + await admin.goto(`${base}/adm/index.php?sid=${sid}`); + if(await admin.locator('input[type="password"]').count()){ + await admin.locator('input[type="password"]').fill('KbTest1234!'); + await admin.waitForFunction(()=>{const e=document.querySelector('[name="creation_time"]');return !e||Date.now()/1000>Number(e.value)+2;}); + await admin.locator('[name="login"][type="submit"]').click(); + } + if(await admin.getByRole('link',{name:/Proceed to the ACP/}).count())await admin.getByRole('link',{name:/Proceed to the ACP/}).click(); + sid=(await admin.context().cookies()).find(c=>c.name.endsWith('_sid')).value; + await shot(admin,'07-acp-configuration',`adm/index.php?i=-phpbbmodders-knowledgebase-acp-config_module&mode=settings&sid=${sid}`,'#acp'); + await shot(admin,'11-acp-search-settings',`adm/index.php?i=-phpbbmodders-knowledgebase-acp-search_module&mode=index&sid=${sid}`,'#acp'); + if(process.env.KB_SCREENSHOT_MANIFEST)fs.writeFileSync(process.env.KB_SCREENSHOT_MANIFEST,JSON.stringify(manifest.map(item=>({...item,url:item.url.replace(/([?&])sid=[^&]+/g,'$1').replace(/[?&]$/,'')})),null,2)+'\n'); + console.log(JSON.stringify({result:'PASS',screenshots:manifest.length})); + }finally{tree(previous);await browser.close();} +})(); diff --git a/tests/kb_article_text_test.php b/tests/kb_article_text_test.php index 7194b41..da6d46b 100644 --- a/tests/kb_article_text_test.php +++ b/tests/kb_article_text_test.php @@ -109,6 +109,32 @@ public function test_the_renderer_setting_is_restored_even_if_rendering_fails() } } + public function test_markdown_search_excerpt_keeps_formatting_and_closes_cut_elements() + { + $this->renderer->method('get_viewcensors')->willReturn(false); + $this->renderer->method('render')->willReturn('

    Guide

    Résumé & notes follow

    Hidden tail

    '); + $html = $this->article_text->search_display('stored Markdown', '', 13); + $this->assertStringContainsString('

    Guide

    ', $html); + $this->assertStringContainsString('Résumé &…', $html); + $this->assertStringNotContainsString('Hidden tail', $html); + } + + public function test_unlimited_markdown_search_renders_the_stored_article() + { + $this->renderer->method('get_viewcensors')->willReturn(false); + $this->renderer->expects($this->once())->method('render')->with('stored')->willReturn('

    Title

    1. Step
    '); + $this->assertSame('

    Title

    1. Step
    ', $this->article_text->search_display('stored', '', -1)); + } + + public function test_markdown_excerpt_preserves_escaped_text_instead_of_creating_html() + { + $this->renderer->method('get_viewcensors')->willReturn(false); + $this->renderer->method('render')->willReturn('

    <script>alert(1)</script>

    '); + $html = $this->article_text->search_display('stored', '', 12); + $this->assertStringNotContainsString('assertStringContainsString('<script>', $html); + } + public function test_editing_a_markdown_article_gives_back_the_source_escaped_for_a_textarea() { $this->utils->expects($this->once())->method('unparse')->with('x')->willReturn("**x** < y & \"z\""); diff --git a/tests/kb_author_change_controller_test.php b/tests/kb_author_change_controller_test.php new file mode 100644 index 0000000..6cc3f57 --- /dev/null +++ b/tests/kb_author_change_controller_test.php @@ -0,0 +1,69 @@ +controller($user_id, $view, $permission, $status, $draft_edit); + $this->assertSame($expected, $controller->main()->getStatusCode()); + } + + public function access_cases(): array + { + return [ + 'guest' => [1, true, true, 2, false, 403], + 'no KB access' => [2, false, true, 2, false, 403], + 'no transfer permission' => [2, true, false, 2, false, 403], + 'private draft' => [2, true, true, 4, false, 404], + 'private draft edit' => [2, true, true, 2, true, 409], + ]; + } + + public function test_invalid_csrf_cannot_call_transfer(): void + { + $controller = $this->controller(2, true, true, 2, false, true); + $response = $controller->main(); + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('FORM_INVALID', $response->getContent()); + } + + private function controller(int $user_id, bool $view, bool $permission, int $status, bool $draft_edit, bool $submit = false): change_author + { + $GLOBALS['kb_test_valid_forms'] = []; + $db = $this->createMock(\phpbb\db\driver\driver_interface::class); + $db->method('sql_fetchrow')->willReturn(['article_id' => 1, 'article_category_id' => 1, 'author_id' => 3, 'author_type' => 0, 'author' => 'Author', 'article_title' => 'Guide', 'status' => $status]); + $auth = $this->createMock(\phpbb\auth\auth::class); + $auth->method('acl_get')->willReturnCallback(fn($permission) => $view && $permission === 'u_kb_view'); + $request = $this->createMock(\phpbb\request\request_interface::class); + $request->method('variable')->willReturnCallback(fn($name, $default) => ['k' => 1, 'old_author_id' => 3, 'old_author_type' => 0][$name] ?? $default); + $request->method('is_set_post')->willReturn($submit); + $kb = $this->createMock(\phpbbmodders\knowledgebase\inc\functions_kb::class); + $kb->method('get_article_categories')->willReturn([1]); + $kb->method('acl_kb_get_any')->willReturn($permission); + $kb->method('article_has_draft_edit')->willReturn($draft_edit); + $kb->method('get_kb_team_group_names')->willReturn([]); + $kb->method('get_author_display')->willReturn('Author'); + $transfer = $this->createMock(\phpbbmodders\knowledgebase\inc\author_change::class); + $transfer->expects($this->never())->method('transfer'); + $user = $this->createMock(\phpbb\user::class); + $user->data = ['user_id' => $user_id]; + $lang = $this->createMock(\phpbb\language\language::class); + $lang->method('lang')->willReturnArgument(0); + $template = $this->createMock(\phpbb\template\template::class); + $errors = ''; + $template->method('assign_vars')->willReturnCallback(function ($values) use (&$errors) { $errors = $values['ERROR'] ?? $values['NOTICE_MESSAGE'] ?? ''; }); + $helper = $this->createMock(\phpbb\controller\helper::class); + $helper->method('route')->willReturn('/knowledgebase/article?k=1'); + $helper->method('render')->willReturnCallback(function ($file, $title, $status = 200) use (&$errors) { return new \Symfony\Component\HttpFoundation\Response($errors, $status); }); + return new change_author($db, $helper, $lang, $auth, $request, $template, $user, $kb, $transfer, 'articles'); + } +} diff --git a/tests/kb_author_change_database_test.php b/tests/kb_author_change_database_test.php new file mode 100644 index 0000000..1b4f545 --- /dev/null +++ b/tests/kb_author_change_database_test.php @@ -0,0 +1,89 @@ +markTestSkipped('Set the disposable kb_fixture database port'); + } + $db = $engine === 'mysql' ? new \phpbb\db\driver\mysqli() : new \phpbb\db\driver\postgres(); + $db->sql_connect('127.0.0.1', $engine === 'mysql' ? 'root' : 'postgres', '', 'kb_fixture', (int) $port); + $prefix = 'kb_author_test_'; + $tables = ['articles', 'coauthors', 'drafts', 'results', 'kb_options', 'kb_users', 'kb_groups']; + try + { + foreach ($tables as $name) $db->sql_query('DROP TABLE IF EXISTS ' . $prefix . $name); + $db->sql_query('CREATE TABLE ' . $prefix . 'articles (article_id INTEGER PRIMARY KEY, author_id INTEGER, author_type INTEGER, author VARCHAR(255), article_title VARCHAR(255), status INTEGER)'); + $db->sql_query("INSERT INTO " . $prefix . "articles VALUES (1, 2, 0, 'Original', 'Guide', 2)"); + $db->sql_query('CREATE TABLE ' . $prefix . 'coauthors (article_id INTEGER, author_type INTEGER, author_ref_id INTEGER)'); + $db->sql_query('INSERT INTO ' . $prefix . 'coauthors VALUES (1, 0, 3), (1, 0, 4)'); + $db->sql_query('CREATE TABLE ' . $prefix . 'drafts (article_id INTEGER)'); + $db->sql_query('CREATE TABLE ' . $prefix . 'results (search_key VARCHAR(32))'); + $db->sql_query("INSERT INTO " . $prefix . "results VALUES ('cached-author')"); + $cache = $this->createMock(\phpbb\cache\driver\driver_interface::class); + $cache->expects($this->once())->method('destroy')->with('_kb_search_results_cached-author'); + $log = $this->createMock(\phpbb\log\log_interface::class); + $log->expects($this->once())->method('add')->willReturn(1); + $user = $this->createMock(\phpbb\user::class); + $user->data = ['user_id' => 9]; + $user->ip = '127.0.0.1'; + $service = new \phpbbmodders\knowledgebase\inc\author_change($db, $cache, $log, $user, $prefix . 'articles', $prefix . 'coauthors', $prefix . 'drafts', $prefix . 'results'); + $result = $db->sql_query('SELECT * FROM ' . $prefix . 'articles'); + $article = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + $this->assertTrue($service->transfer($article, 3, 0, "O'Brien")); + $result = $db->sql_query('SELECT * FROM ' . $prefix . 'articles'); + $row = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + $this->assertSame(3, (int) $row['author_id']); + $this->assertSame("O'Brien", $row['author']); + $result = $db->sql_query('SELECT author_ref_id FROM ' . $prefix . 'coauthors'); + $this->assertSame(4, (int) $db->sql_fetchfield('author_ref_id')); + $db->sql_freeresult($result); + + // Run the permission migration on an existing options table, twice. + $identity = $engine === 'mysql' ? 'INTEGER AUTO_INCREMENT' : 'SERIAL'; + $db->sql_query('CREATE TABLE ' . $prefix . 'kb_options (auth_option_id ' . $identity . ' PRIMARY KEY, auth_option VARCHAR(50) UNIQUE, is_global INTEGER, is_local INTEGER)'); + foreach (['kb_users', 'kb_groups'] as $table) + { + $db->sql_query('CREATE TABLE ' . $prefix . $table . ' (auth_option_id INTEGER)'); + } + $db->sql_query("INSERT INTO " . $prefix . "kb_options (auth_option, is_global, is_local) VALUES ('kb_m_edit', 0, 1)"); + $tools = $this->createMock(\phpbb\db\tools\tools_interface::class); + $migration = new \phpbbmodders\knowledgebase\migrations\version_2_0_30(new \phpbb\config\config([]), $db, $tools, '', 'php', $prefix, []); + $migration->add_permission(); + $migration->add_permission(); + $result = $db->sql_query("SELECT auth_option_id FROM " . $prefix . "kb_options WHERE auth_option='kb_m_change_author'"); + $id = (int) $db->sql_fetchfield('auth_option_id'); + $db->sql_freeresult($result); + $this->assertGreaterThan(0, $id); + foreach (['kb_users', 'kb_groups'] as $table) $db->sql_query('INSERT INTO ' . $prefix . $table . ' VALUES (' . $id . ')'); + $migration->remove_permission(); + $migration->remove_permission(); + $result = $db->sql_query('SELECT auth_option FROM ' . $prefix . 'kb_options'); + $this->assertSame('kb_m_edit', $db->sql_fetchfield('auth_option')); + $db->sql_freeresult($result); + } + finally + { + foreach ($tables as $name) $db->sql_query('DROP TABLE IF EXISTS ' . $prefix . $name); + $db->sql_close(); + } + } + + public function engines(): array + { + return [['mysql'], ['postgres']]; + } +} diff --git a/tests/kb_author_change_test.php b/tests/kb_author_change_test.php new file mode 100644 index 0000000..0b7ef71 --- /dev/null +++ b/tests/kb_author_change_test.php @@ -0,0 +1,144 @@ +pdo = new \PDO('sqlite::memory:'); + $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $this->pdo->exec('CREATE TABLE articles (article_id INTEGER PRIMARY KEY, author_id INTEGER, author_type INTEGER, author TEXT, article_title TEXT, status INTEGER, body TEXT, edited INTEGER)'); + $this->pdo->exec("INSERT INTO articles VALUES (1, 2, 0, 'Original', 'Guide', 2, 'Keep content', 123)"); + $this->pdo->exec('CREATE TABLE co_authors (article_id INTEGER, author_type INTEGER, author_ref_id INTEGER)'); + $this->pdo->exec('INSERT INTO co_authors VALUES (1, 0, 3), (1, 0, 4), (1, 1, 5)'); + $this->pdo->exec('CREATE TABLE drafts (article_id INTEGER, body TEXT)'); + $this->pdo->exec('CREATE TABLE revisions (article_id INTEGER, submitted_by INTEGER, body TEXT)'); + $this->pdo->exec("INSERT INTO revisions VALUES (1, 2, 'Pending revision')"); + $this->pdo->exec('CREATE TABLE results (search_key TEXT)'); + $this->pdo->exec("INSERT INTO results VALUES ('author-filter')"); + $this->db = $this->createMock(\phpbb\db\driver\driver_interface::class); + $affected = 0; + $this->db->method('sql_query')->willReturnCallback(function ($sql) use (&$affected) { + $result = $this->pdo->query($sql); + $affected = $result->rowCount(); + return $result; + }); + $this->db->method('sql_affectedrows')->willReturnCallback(function () use (&$affected) { return $affected; }); + $this->db->method('sql_fetchrow')->willReturnCallback(fn($result) => $result->fetch(\PDO::FETCH_ASSOC)); + $this->db->method('sql_build_array')->willReturnCallback(function ($mode, $data) { + return implode(', ', array_map(fn($key) => $key . ' = ' . $this->pdo->quote((string) $data[$key]), array_keys($data))); + }); + $this->db->method('sql_transaction')->willReturnCallback(function ($mode) { + return match ($mode) { + 'begin' => $this->pdo->beginTransaction(), + 'commit' => $this->pdo->commit(), + 'rollback' => $this->pdo->rollBack(), + }; + }); + $this->cache = $this->createMock(\phpbb\cache\driver\driver_interface::class); + $this->log = $this->createMock(\phpbb\log\log_interface::class); + $this->log->method('add')->willReturn(1); + $user = $this->createMock(\phpbb\user::class); + $user->data = ['user_id' => 9]; + $user->ip = '127.0.0.1'; + $this->service = new author_change($this->db, $this->cache, $this->log, $user, 'articles', 'co_authors', 'drafts', 'results'); + } + + private function article(): array + { + return $this->pdo->query('SELECT * FROM articles WHERE article_id = 1')->fetch(\PDO::FETCH_ASSOC); + } + + public function test_transfer_preserves_content_revisions_and_unrelated_coauthors(): void + { + $before = $this->article(); + $this->log->expects($this->once())->method('add')->with('admin', 9, '127.0.0.1', 'LOG_LIBRARY_CHANGED_AUTHOR', $this->anything(), ['Guide', 'Original (0:2)', 'New author (0:3)']); + $this->cache->expects($this->once())->method('destroy')->with('_kb_search_results_author-filter'); + $this->assertTrue($this->service->transfer($before, 3, 0, 'New author')); + $after = $this->article(); + $this->assertSame('New author', $after['author']); + $this->assertSame(3, $after['author_id']); + foreach (['body', 'edited', 'status'] as $field) + { + $this->assertSame($before[$field], $after[$field]); + } + $this->assertSame([4, 5], $this->pdo->query('SELECT author_ref_id FROM co_authors ORDER BY author_ref_id')->fetchAll(\PDO::FETCH_COLUMN)); + $this->assertSame(['article_id' => 1, 'submitted_by' => 2, 'body' => 'Pending revision'], $this->pdo->query('SELECT * FROM revisions')->fetch(\PDO::FETCH_ASSOC)); + } + + public function test_team_transfer_does_not_remove_a_user_with_the_same_numeric_id(): void + { + $this->assertTrue($this->service->transfer($this->article(), 3, 1, 'Team')); + $this->assertSame(3, (int) $this->pdo->query('SELECT COUNT(*) FROM co_authors')->fetchColumn()); + $this->assertSame(1, $this->article()['author_type']); + } + + public function test_same_author_does_not_log_or_write(): void + { + $this->log->expects($this->never())->method('add'); + $this->db->expects($this->never())->method('sql_query'); + $this->assertFalse($this->service->transfer($this->article(), 2, 0, 'Original')); + } + + public function test_private_draft_edit_blocks_transfer(): void + { + $this->pdo->exec("INSERT INTO drafts VALUES (1, 'Private content')"); + $this->log->expects($this->never())->method('add'); + try + { + $this->service->transfer($this->article(), 3, 0, 'New'); + $this->fail('A private draft edit must not transfer'); + } + catch (\RuntimeException $error) + { + $this->assertSame('KB_CHANGE_AUTHOR_CONFLICT', $error->getMessage()); + } + $this->assertSame(2, $this->article()['author_id']); + $this->assertSame(3, (int) $this->pdo->query('SELECT COUNT(*) FROM co_authors')->fetchColumn()); + } + + public function test_stale_owner_does_not_overwrite_another_transfer(): void + { + $before = $this->article(); + $this->pdo->exec('UPDATE articles SET author_id = 7'); + $this->expectExceptionMessage('KB_CHANGE_AUTHOR_CONFLICT'); + $this->service->transfer($before, 3, 0, 'New'); + } + + public function test_log_failure_rolls_back_the_author_and_coauthors(): void + { + $this->log->method('add')->willThrowException(new \RuntimeException('Log unavailable')); + try + { + $this->service->transfer($this->article(), 3, 0, 'New'); + $this->fail('Log failure must be visible'); + } + catch (\RuntimeException $error) + { + $this->assertSame('Log unavailable', $error->getMessage()); + } + $this->assertSame(2, $this->article()['author_id']); + $this->assertSame(3, (int) $this->pdo->query('SELECT COUNT(*) FROM co_authors')->fetchColumn()); + } + + public function test_private_draft_article_is_rejected(): void + { + $this->pdo->exec('UPDATE articles SET status = ' . functions_kb::STATUS_DRAFT); + $this->expectExceptionMessage('KB_CHANGE_AUTHOR_PRIVATE_DRAFT'); + $this->service->transfer($this->article(), 3, 0, 'New'); + } +} diff --git a/textformatter/article_text.php b/textformatter/article_text.php index df6a265..52c6ba5 100644 --- a/textformatter/article_text.php +++ b/textformatter/article_text.php @@ -114,6 +114,60 @@ public function display(string $stored, string $uid, bool $markdown, bool $censo } } + /** Render a Markdown search excerpt without cutting stored XML or HTML tags. */ + public function search_display(string $stored, string $uid, int $length): string + { + $html = $this->display($stored, $uid, true, false); + if ($length <= 0) + { + return $html; + } + $document = new \DOMDocument('1.0', 'UTF-8'); + $previous_errors = libxml_use_internal_errors(true); + try + { + $document->loadHTML('
    ' . $html . '
    ', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + } + finally + { + libxml_clear_errors(); + libxml_use_internal_errors($previous_errors); + } + $root = $document->getElementById('kb-search-excerpt'); + $remaining = $length; + $trim = function (\DOMNode $node) use (&$trim, &$remaining): void + { + foreach (iterator_to_array($node->childNodes) as $child) + { + if ($remaining <= 0) + { + $node->removeChild($child); + } + else if ($child instanceof \DOMText) + { + $text = $child->nodeValue; + $size = utf8_strlen($text); + if ($size > $remaining) + { + $child->nodeValue = utf8_substr($text, 0, $remaining) . '…'; + } + $remaining -= $size; + } + else + { + $trim($child); + } + } + }; + $trim($root); + $result = ''; + foreach ($root->childNodes as $child) + { + $result .= $document->saveHTML($child); + } + return $result; + } + /** * Turn stored Markdown text back into what the author typed, escaped for * use inside a textarea.