diff --git a/API.md b/API.md index 796a444d92..d64ba6697a 100644 --- a/API.md +++ b/API.md @@ -52,6 +52,7 @@ be acquired from the admin configuration. ### FAQ related APIs - [Add FAQ](api-docs/faq/post.md): `POST /api/v2.2/faq` +- [Update FAQ](api-docs/faq/put.md): `PUT /api/v2.2/faq/:categoryId/:faqId` - [Add question](api-docs/question/post.md): `POST /api/v2.2/question` ### Groups related APIs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6cc57623..331b5a6d96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ This is a log of major user-visible changes in each phpMyFAQ release. - added experimental support for PHP 8.3 (Thorsten) - updated to PNPM (Thorsten) -### phpMyFAQ v3.2.0 - 2023-07- +### phpMyFAQ v3.2.0-RC.4 - 2023-08- - changed PHP requirement to PHP 8.1.0 or later (Thorsten) - changed to HTTPS as new default (Thorsten) @@ -28,7 +28,7 @@ This is a log of major user-visible changes in each phpMyFAQ release. - added 2FA (Two-Factor Authentication) (Jan Harms) - added experimental Azure AD login (Thorsten) - added option to use Google ReCaptcha (Thorsten) -- added REST API v2.2 to fetch groups and add categories (Thorsten) +- added REST API v2.2 to fetch groups, add categories, and update FAQs (Thorsten) - added verification of backup files (Thorsten) - added option to disable questions and notifications (Thorsten) - added new options for more flexibility (Jan Harms) @@ -47,6 +47,11 @@ This is a log of major user-visible changes in each phpMyFAQ release. - updated Japanese translation (Advanced Bear) - updated Dutch translation (Bob Coret) +### phpMyFAQ v3.1.17 - 2023-08-27 + +- fixed multiple security vulnerabilities (Thorsten) +- fixed minor bugs (Thorsten) + ### phpMyFAQ v3.1.16 - 2023-07-16 - fixed multiple security vulnerabilities (Thorsten) diff --git a/api-docs/faq/post.md b/api-docs/faq/post.md index e94fbe9d22..bd3d7f1e13 100644 --- a/api-docs/faq/post.md +++ b/api-docs/faq/post.md @@ -41,7 +41,7 @@ mapped, the category ID from the name will be used. If the category name cannot ```json { "language": "de", - "category-id": "1", + "category-id": 1, "category-name": "Queen Songs", "question": "Is this the world we created?", "answer": "What did we do it for, is this the world we invaded, against the law, so it seems in the end, is this what we're all living for today", diff --git a/api-docs/faq/put.md b/api-docs/faq/put.md new file mode 100644 index 0000000000..3c49882758 --- /dev/null +++ b/api-docs/faq/put.md @@ -0,0 +1,80 @@ +# Update a FAQ + +Used to update a FAQ in one existing category. + +**URL** : `/api/v2.1/faq` + +**HTTP Header** : + +``` +Accept-Language: [language code] +X-PMF-Token: [phpMyFAQ client API Token, generated in admin backend] +Content-Type: application/json +``` + +**Method** : `POST` + +**Auth required** : NO + +**Data constraints** + +```json +{ + "faq-id": "[faq id as integer value, required value]", + "language": "[language code, required value]", + "category-id": "[category id as integer value, required value]", + "question": "[question in plain text, required value]", + "answer": "[question in plain text, required value]", + "keywords": "[keywords in comma separated plain text or empty string, required value]", + "author": "[author name in plain text, required value]", + "email": "[author email in plain text, required value]", + "is-active": "true/false, required value", + "is-sticky": "true/false, required value" +} +``` + +**Data example** + +```json +{ + "faq-id": 1, + "language": "de", + "category-id": 1, + "question": "Is this the world we created?", + "answer": "What did we do it for, is this the world we invaded, against the law, so it seems in the end, is this what we're all living for today", + "keywords": "phpMyFAQ, FAQ, Foo, Bar", + "author": "Freddie Mercury", + "email": "freddie.mercury@example.org", + "is-active": "true", + "is-sticky": "false" +} +``` + +## Success Response + +**Condition** : If all putted data is correct. + +**Code** : `200 OK` + +**Content example** + +```json +{ + "stored": true +} +``` + +## Error Responses + +**Condition** : If FAQ ID or category id cannot be mapped to valid IDs + +**Code** : `404 Not Found` + +**Content** : + +```json +{ + "stored": false, + "error": "error message" +} +``` diff --git a/phpmyfaq/admin/assets/src/content/tags.js b/phpmyfaq/admin/assets/src/content/tags.js index 7afd275402..b27c013853 100644 --- a/phpmyfaq/admin/assets/src/content/tags.js +++ b/phpmyfaq/admin/assets/src/content/tags.js @@ -110,14 +110,15 @@ export const handleTags = () => { input: tagsAutocomplete, minLength: 1, onSelect: async (item, input) => { - let currentTags = input.getAttribute('data-tag-list'); + let currentTags = input.value; + let currentTagsArray = currentTags.split(','); if (currentTags.length === 0) { currentTags = item.tagName; } else { - currentTags = currentTags + ', ' + item.tagName; + currentTagsArray[currentTagsArray.length - 1] = item.tagName; + currentTags = currentTagsArray.join(','); } input.value = currentTags; - input.setAttribute('data-tag-list', currentTags); }, fetch: async (text, callback) => { let match = text.toLowerCase(); diff --git a/phpmyfaq/admin/header.php b/phpmyfaq/admin/header.php index 9ede23ad98..7537b1a8f2 100644 --- a/phpmyfaq/admin/header.php +++ b/phpmyfaq/admin/header.php @@ -110,7 +110,7 @@ $secLevelEntries['backup'] = $adminHelper->addMenuEntry('editconfig', 'backup', 'ad_menu_backup', $action); $secLevelEntries['config'] = $adminHelper->addMenuEntry('editconfig', 'config', 'ad_menu_editconfig', $action); -$secLevelEntries['config'] .= $adminHelper->addMenuEntry('editconfig', 'system', 'ad_system_info', $action, false); +$secLevelEntries['config'] .= $adminHelper->addMenuEntry('editconfig', 'system', 'ad_system_info', $action); $secLevelEntries['config'] .= $adminHelper->addMenuEntry( 'editinstances+addinstances+delinstances', 'instances', diff --git a/phpmyfaq/admin/login.php b/phpmyfaq/admin/login.php index 1373a15492..ea75b1c0a5 100644 --- a/phpmyfaq/admin/login.php +++ b/phpmyfaq/admin/login.php @@ -75,7 +75,7 @@
- diff --git a/phpmyfaq/admin/record.edit.php b/phpmyfaq/admin/record.edit.php index 1f08a6d319..0749b6a134 100644 --- a/phpmyfaq/admin/record.edit.php +++ b/phpmyfaq/admin/record.edit.php @@ -188,6 +188,7 @@ $faq->getRecord($faqData['id'], null, true); $faqData = $faq->faqRecord; + $faqData['tags'] = implode(', ', $tagging->getAllTagsById($faqData['id'])); $queryString = 'insertentry'; } else { $logging = new AdminLog($faqConfig); @@ -227,7 +228,7 @@ } // Set data for forms - $faqData['title'] = (isset($faqData['title']) ? Strings::htmlentities($faqData['title']) : ''); + $faqData['title'] = (isset($faqData['title']) ? Strings::htmlentities($faqData['title'], ENT_HTML5 | ENT_COMPAT) : ''); $faqData['content'] = (isset($faqData['content']) ? trim(Strings::htmlentities($faqData['content'], ENT_COMPAT, 'utf-8', true)) : ''); $faqData['tags'] = (isset($faqData['tags']) ? Strings::htmlentities($faqData['tags']) : ''); @@ -329,7 +330,7 @@
perm->hasPermission($currentUserId, 'changebtrevs')) { + if ($user->perm->hasPermission($currentUserId, 'changebtrevs') && $action === 'editentry') { $faqRevision = new Revision($faqConfig); $revisions = $faqRevision->get($faqData['id'], $faqData['lang'], $faqData['author']); if (count($revisions)) { ?> @@ -395,7 +396,7 @@ class="form-select"> + value="">
@@ -512,8 +513,7 @@ class="form-control">
+ class="form-control pmf-tags-autocomplete">
diff --git a/phpmyfaq/admin/record.save.php b/phpmyfaq/admin/record.save.php index 79eded110f..8a63500356 100644 --- a/phpmyfaq/admin/record.save.php +++ b/phpmyfaq/admin/record.save.php @@ -137,7 +137,7 @@ // Create ChangeLog entry $changelog = new Changelog($faqConfig); - $changelog->add($recordId, $user->getUserId(), nl2br((string) $changed), $recordLang, $revisionId); + $changelog->add($recordId, $user->getUserId(), (string) $changed, $recordLang, $revisionId); // Create the visit entry $visits = new Visits($faqConfig); diff --git a/phpmyfaq/admin/record.show.php b/phpmyfaq/admin/record.show.php index df8ba8473e..6a62487725 100644 --- a/phpmyfaq/admin/record.show.php +++ b/phpmyfaq/admin/record.show.php @@ -164,7 +164,7 @@ ] ); - if (is_numeric($searchTerm)) { + if (is_numeric($searchTerm) && $faqConfig->get('search.searchForSolutionId')) { $search->setMatchingColumns([$fdTable . '.solution_id']); } else { $search->setMatchingColumns([$fdTable . '.thema', $fdTable . '.content', $fdTable . '.keywords']); diff --git a/phpmyfaq/api.php b/phpmyfaq/api.php index de2e4c4393..28414356ad 100644 --- a/phpmyfaq/api.php +++ b/phpmyfaq/api.php @@ -288,7 +288,7 @@ $faq->setUser($currentUser); $faq->setGroups($currentGroups); - if ($recordId > 0) { + if ($request->getMethod() === 'GET' && $recordId > 0) { $faq->getRecord($recordId); $result = $faq->faqRecord; @@ -310,7 +310,7 @@ } // - // POST + // POST or PUT // if ($faqConfig->get('api.apiClientToken') !== $request->headers->get('x-pmf-token')) { $response->setStatusCode(Response::HTTP_UNAUTHORIZED); @@ -329,6 +329,11 @@ $postData = json_decode(file_get_contents('php://input'), true, 512, JSON_THROW_ON_ERROR); + if (isset($postData['faq-id'])) { + $faqId = Filter::filterVar($postData['faq-id'], FILTER_VALIDATE_INT); + } else { + $faqId = null; + } $languageCode = Filter::filterVar($postData['language'], FILTER_SANITIZE_SPECIAL_CHARS); $categoryId = Filter::filterVar($postData['category-id'], FILTER_VALIDATE_INT); if (isset($postData['category-name'])) { @@ -377,14 +382,18 @@ ->setComment(false) ->setNotes(''); - $faqId = $faq->create($faqData); + if (is_null($faqId)) { + $faqId = $faq->create($faqData); + } else { + $faqData->setId($faqId); + $faqData->setRevisionId(0); + $faq->update($faqData); + } - $faqMetaData = new FaqMetaData($faqConfig); - $faqMetaData - ->setFaqId($faqId) - ->setFaqLanguage($languageCode) - ->setCategories($categories) - ->save(); + if ($request->getMethod() !== 'PUT') { + $faqMetaData = new FaqMetaData($faqConfig); + $faqMetaData->setFaqId($faqId)->setFaqLanguage($languageCode)->setCategories($categories)->save(); + } $result = [ 'stored' => true diff --git a/phpmyfaq/faq.php b/phpmyfaq/faq.php index 68674347ff..a0bdb2bbbf 100644 --- a/phpmyfaq/faq.php +++ b/phpmyfaq/faq.php @@ -77,7 +77,7 @@ $currentCategory = $cat; $request = Request::createFromGlobals(); -$faqId = Filter::filterVar($request->query->get('id'), FILTER_VALIDATE_INT); +$faqId = Filter::filterVar($request->query->get('id'), FILTER_VALIDATE_INT, 0); $solutionId = Filter::filterVar($request->query->get('solution_id'), FILTER_VALIDATE_INT); $highlight = Filter::filterVar($request->query->get('highlight'), FILTER_SANITIZE_SPECIAL_CHARS); $bookmarkAction = Filter::filterVar($request->query->get('bookmark_action'), FILTER_SANITIZE_SPECIAL_CHARS); diff --git a/phpmyfaq/index.php b/phpmyfaq/index.php index 8aa3563771..62ed01f2e8 100755 --- a/phpmyfaq/index.php +++ b/phpmyfaq/index.php @@ -332,8 +332,8 @@ // // Found a record ID? // -$id = Filter::filterVar($request->query->get('id'), FILTER_VALIDATE_INT); -if (!is_null($id)) { +$id = Filter::filterVar($request->query->get('id'), FILTER_VALIDATE_INT, 0); +if ($id !== 0) { $faq->getRecord($id); $title = ' - ' . $faq->faqRecord['title']; $keywords = ',' . $faq->faqRecord['keywords']; @@ -350,7 +350,6 @@ $faqLink->itemTitle = $faq->faqRecord['title']; $currentPageUrl = $faqLink->toString(true); } else { - $id = ''; $title = ' - ' . System::getPoweredByString(); $keywords = ''; $metaDescription = str_replace('"', '', (string) $faqConfig->get('main.metaDescription')); diff --git a/phpmyfaq/search.php b/phpmyfaq/search.php index 2333b52cf6..502ed8b7db 100755 --- a/phpmyfaq/search.php +++ b/phpmyfaq/search.php @@ -103,7 +103,7 @@ $tagHelper->setTaggingIds($tagIds); foreach ($tagIds as $tagId) { - if (!isset($tags[$tagId])) { + if (!isset($tags[$tagId]) && is_numeric($tagId)) { $tags[$tagId] = $tagging->getTagNameById($tagId); } } diff --git a/phpmyfaq/show.php b/phpmyfaq/show.php index 639eb63f23..6f3995961f 100644 --- a/phpmyfaq/show.php +++ b/phpmyfaq/show.php @@ -20,6 +20,7 @@ use phpMyFAQ\Helper\CategoryHelper; use phpMyFAQ\Language\Plurals; use phpMyFAQ\Link; +use phpMyFAQ\Strings; use phpMyFAQ\Translation; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -114,7 +115,7 @@ [ 'categoryHeader' => Translation::get('msgEntriesIn') . $categoryData->getName(), 'categoryFaqsHeader' => $categoryData->getName(), - 'categoryDescription' => $categoryData->getDescription(), + 'categoryDescription' => Strings::htmlentities($categoryData->getDescription()), 'categorySubsHeader' => Translation::get('msgSubCategories'), 'categoryContent' => $records, 'subCategoryContent' => $subCategoryContent, diff --git a/phpmyfaq/src/phpMyFAQ/Category.php b/phpmyfaq/src/phpMyFAQ/Category.php index a28902ebd8..50b26843e0 100755 --- a/phpmyfaq/src/phpMyFAQ/Category.php +++ b/phpmyfaq/src/phpMyFAQ/Category.php @@ -729,7 +729,7 @@ public function getPath( $categoryId[$key] ); $oLink = new Link($url, $this->config); - $oLink->text = sprintf('%s', Strings::htmlentities($category)); + $oLink->text = Strings::htmlentities($category); $oLink->itemTitle = Strings::htmlentities($category); $oLink->tooltip = Strings::htmlentities($description[$key] ?? ''); if (0 === $key) { diff --git a/phpmyfaq/src/phpMyFAQ/Database/Mysqli.php b/phpmyfaq/src/phpMyFAQ/Database/Mysqli.php index 835eb699a2..cfaafaddc9 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/Mysqli.php +++ b/phpmyfaq/src/phpMyFAQ/Database/Mysqli.php @@ -84,7 +84,7 @@ public function connect( } // change character set to UTF-8 - if (!$this->conn->set_charset('utf8')) { + if (!$this->conn->set_charset('utf8mb4')) { Database::errorPage($this->error()); } diff --git a/phpmyfaq/src/phpMyFAQ/Faq.php b/phpmyfaq/src/phpMyFAQ/Faq.php index 3dcd3817c1..b4aa6dbb64 100755 --- a/phpmyfaq/src/phpMyFAQ/Faq.php +++ b/phpmyfaq/src/phpMyFAQ/Faq.php @@ -2404,8 +2404,8 @@ public function getStickyRecords(): array if (count($result) > 0) { foreach ($result as $row) { - $output['title'][] = Utils::makeShorterText($row['question'], 8); - $output['preview'][] = $row['question']; + $output['title'][] = Utils::makeShorterText(Strings::htmlentities($row['question']), 8); + $output['preview'][] = Strings::htmlentities($row['question']); $output['url'][] = Strings::htmlentities($row['url']); } } else { diff --git a/phpmyfaq/src/phpMyFAQ/Helper/FaqHelper.php b/phpmyfaq/src/phpMyFAQ/Helper/FaqHelper.php index 95fa0587b7..27349ae327 100644 --- a/phpmyfaq/src/phpMyFAQ/Helper/FaqHelper.php +++ b/phpmyfaq/src/phpMyFAQ/Helper/FaqHelper.php @@ -165,7 +165,10 @@ public function createOverview(Category $category, Faq $faq, string $language = $lastCategory = 0; foreach ($faq->faqRecords as $data) { if (!is_null($data['category_id']) && $data['category_id'] !== $lastCategory) { - $output .= sprintf('

%s

', $category->getPath($data['category_id'], ' » ')); + $output .= sprintf( + '

%s

', + $this->cleanUpContent($category->getPath($data['category_id'], ' » ')) + ); } $output .= sprintf('

%s

', Strings::htmlentities($data['title'])); @@ -239,6 +242,7 @@ public function cleanUpContent(string $content): string { $htmlSanitizer = new HtmlSanitizer( (new HtmlSanitizerConfig()) + ->withMaxInputLength(40000) ->allowSafeElements() ->allowStaticElements() ->allowRelativeLinks() diff --git a/phpmyfaq/src/phpMyFAQ/Helper/SearchHelper.php b/phpmyfaq/src/phpMyFAQ/Helper/SearchHelper.php index 07f171d506..53f50ddb8d 100644 --- a/phpmyfaq/src/phpMyFAQ/Helper/SearchHelper.php +++ b/phpmyfaq/src/phpMyFAQ/Helper/SearchHelper.php @@ -207,7 +207,7 @@ public function renderSearchResult(SearchResultSet $resultSet, int $currentPage) $categoryInfo = $this->Category->getCategoriesFromFaq($result->id); $categoryInfo = array_values($categoryInfo); //Reset the array keys - $question = Utils::chopString($result->question, 15); + $question = Utils::chopString(Strings::htmlentities($result->question), 15); $answerPreview = $faqHelper->renderAnswerPreview($result->answer, 20); $searchTerm = str_replace( @@ -246,7 +246,7 @@ public function renderSearchResult(SearchResultSet $resultSet, int $currentPage) $html .= $this->renderScore($result->score * 33); $html .= sprintf( '%s
%s
', - $this->Category->getPath($categoryInfo[0]['id']), + Strings::htmlentities($this->Category->getPath($categoryInfo[0]['id'])), $oLink->toHtmlAnchor() ); $html .= sprintf( diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php index ce8ca2631e..7f19080223 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php @@ -40,7 +40,7 @@ class Pgsql extends Database implements Driver 'faqattachment' => 'CREATE TABLE %sfaqattachment ( id SERIAL NOT NULL, - record_id SERIAL NOT NULL, + record_id INTEGER NOT NULL, record_lang VARCHAR(5) NOT NULL, real_hash CHAR(32) NOT NULL, virtual_hash CHAR(32) NOT NULL, @@ -114,6 +114,8 @@ class Pgsql extends Database implements Driver position INTEGER NOT NULL, PRIMARY KEY (category_id))', + 'faqcategory_order_position_seq' => 'CREATE SEQUENCE %sfaqcategory_order_position_seq START WITH 1', + 'faqcategory_user' => 'CREATE TABLE %sfaqcategory_user ( category_id INTEGER NOT NULL, user_id INTEGER NOT NULL, @@ -327,6 +329,8 @@ class Pgsql extends Database implements Driver jwt TEXT NULL DEFAULT NULL, PRIMARY KEY (user_id))', + 'faquser_user_id_seq' => 'CREATE SEQUENCE %sfaquser_user_id_seq START WITH 2', + 'faquserdata' => 'CREATE TABLE %sfaquserdata ( user_id SERIAL NOT NULL, last_modified VARCHAR(14) NULL, @@ -372,7 +376,7 @@ class Pgsql extends Database implements Driver /** * Constructor. * - * @param PMF_Configuration $config + * @param Configuration $config */ public function __construct(Configuration $config) { @@ -385,7 +389,7 @@ public function __construct(Configuration $config) * * @return bool */ - public function createTables(string $prefix = '') + public function createTables(string $prefix = ''): bool { foreach ($this->createTableStatements as $key => $stmt) { if ($key == 'idx_records' || $key == 'faqsessions_idx') { diff --git a/phpmyfaq/src/phpMyFAQ/Link.php b/phpmyfaq/src/phpMyFAQ/Link.php index 1fe065f50a..ae473efaef 100755 --- a/phpmyfaq/src/phpMyFAQ/Link.php +++ b/phpmyfaq/src/phpMyFAQ/Link.php @@ -283,10 +283,10 @@ public function toHtmlAnchor(): string } $htmlAnchor .= '>'; if (('0' == $this->text) || (!empty($this->text))) { - $htmlAnchor .= Strings::htmlentities($this->text); + $htmlAnchor .= $this->text; } else { if (!empty($this->name)) { - $htmlAnchor .= Strings::htmlentities($this->name); + $htmlAnchor .= $this->name; } else { $htmlAnchor .= $url; } diff --git a/phpmyfaq/src/phpMyFAQ/Tags.php b/phpmyfaq/src/phpMyFAQ/Tags.php index 2f1a1049be..8d291ddc1a 100644 --- a/phpmyfaq/src/phpMyFAQ/Tags.php +++ b/phpmyfaq/src/phpMyFAQ/Tags.php @@ -110,6 +110,7 @@ public function getAllTagsById(int $recordId): array public function saveTags(int $recordId, array $tags): bool { $currentTags = $this->getAllTags(); + $registeredTags = []; // Delete all tag references for the faq record if (count($tags) > 0) { @@ -119,7 +120,7 @@ public function saveTags(int $recordId, array $tags): bool // Store tags and references for the faq record foreach ($tags as $tagName) { $tagName = trim($tagName); - if (Strings::strlen($tagName) > 0) { + if (Strings::strlen($tagName) > 0 && !in_array($tagName, $registeredTags, true)) { if ( !in_array( Strings::strtolower($tagName), @@ -156,6 +157,7 @@ public function saveTags(int $recordId, array $tags): bool ); } $this->config->getDb()->query($query); + $registeredTags[] = $tagName; } }