Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ This is a log of major user-visible changes in each phpMyFAQ release.
- added experimental support for API key authentication via OAuth2 (Thorsten)
- added experimental per-tenant quota enforcement and API request rate limits (Thorsten)
- added SBOM (Software Bill of Materials) generation (Thorsten)
- added separate read, write and publish permissions for FAQs (Thorsten)
- improved audit and activity log with comprehensive security event tracking (Thorsten)
- improved API errors with formatted RFC 7807 Problem Details JSON responses (Thorsten)
- improved support for PDO (Thorsten)
Expand Down
35 changes: 33 additions & 2 deletions docs/administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ Keep in mind that new users have no privileges at all; you will have to assign t
A super admin can set users as super admins as well, then the given permissions aren't evaluated, so please be careful
with setting this option.

A user without any permission in the admin section can still get read access to categories and records. You can set
the permissions on categories and records in the category and record administration frontend.
A user without any permission in the admin section can still get read access to categories and records. Reading FAQs
requires the `View FAQs` right, which new accounts receive automatically and which the update grants to every existing
user and group — see 5.1.5. Beyond that, you can set the permissions on categories and records in the category and
record administration frontend.

If you enable LDAP or Microsoft Entra ID authentication, you can't edit the user's profile in the admin area.
The users will be created automatically when they log in the first time.
Expand Down Expand Up @@ -96,6 +98,35 @@ restrictions). The rules are deliberately different from category restrictions:
- Category and language restrictions combine with **AND** semantics: an action must be permitted by both the category
and the language rules to succeed.

### 5.1.5 Read, write and publish rights

Reading FAQs, changing them, and deciding that a change goes public are three independently assignable rights:

| Right | Name in the permission list | Grants |
|---|---|---|
| Read | `View FAQs` (`view_faqs`) | Seeing FAQs at all as a logged-in user |
| Write | `Add`/`Edit`/`Delete FAQs` | Creating and changing FAQ content |
| Publish | `Publish FAQs` (`faq_publish`) | Setting an FAQ live, on its own or in the FAQ overview |

- **A user with write but not publish can edit a published FAQ without changing whether it is public.** The editor shows
the current publication state read-only instead of the visibility radio buttons, and saving leaves that state alone.
The activation checkbox in the FAQ overview is likewise shown read-only.
- `Publish FAQs` is separate from the older `Approve FAQs` (`approverec`) right, which continues to gate the list of
inactive FAQs. Upgrades grant `faq_publish` to everyone who already held `approverec`, carrying their category and
language restrictions across, so no one loses or gains an ability.
- **`View FAQs` is only enforced for logged-in users.** Anonymous visitors are unaffected by it; what they may see is
still governed by `security.enableLoginOnly` and by the per-FAQ user and group permissions set in the FAQ editor.
Revoking `view_faqs` from a user therefore hides FAQs from that account, not from the public site.
- New accounts receive `view_faqs` automatically, and the upgrade grants it to every existing user and group, so
enabling the gate changes nothing until you deliberately revoke it.
- Both rights honour the category and language restrictions described in 5.1.3 and 5.1.4, with the same **AND**
semantics. A user whose `view_faqs` is restricted to one category sees only that category's FAQs — in FAQ pages,
category listings, search results, tag listings, the sitemap and the "latest"/"most popular" lists alike.
- Super admins bypass all of this, as they do every other right.
- FAQs submitted through the frontend are still activated according to the `records.defaultActivation` configuration
option. That is a site-wide setting about anonymous submissions rather than a right exercised by the submitter, so it
is deliberately not gated on `faq_publish`.

## 5.2 Content

### 5.2.1 Category Administration
Expand Down
15 changes: 15 additions & 0 deletions docs/update.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,21 @@ Then go to the "phpMyFAQ Update" page in the configuration section and click thr
Note:
The online update feature is experimental and might not work in all environments.

### Permission changes in phpMyFAQ 4.2

The update separates reading, writing and publishing FAQs into independent rights, without changing what anybody can
currently do:

- The existing `View FAQs` (`view_faqs`) right starts being enforced for logged-in users. The update grants it to every
existing user and group, and new accounts receive it automatically, so nothing becomes invisible. Anonymous visitors
are not affected by the right at all.
- A new `Publish FAQs` (`faq_publish`) right decides whether an FAQ may be set live. The update grants it to every user
and group that currently holds `Approve FAQs` (`approverec`), including their category and language restrictions, so
everyone who can publish today can still publish afterwards.

Afterwards you can revoke `faq_publish` from editors who should be able to write but not publish. See section 5.1.5 of
the administration documentation for the details.

## Modifying templates for phpMyFAQ 4.2

We recommend you take a look at the main [Bootstrap documentation](https://getbootstrap.com/).
Expand Down
42 changes: 42 additions & 0 deletions phpmyfaq/admin/assets/src/content/faqs.overview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ describe('FAQ Overview Functions', () => {
category_id: 3,
sticky: 'no',
active: 'yes',
isAllowedToPublish: true,
},
],
isAllowedToTranslate: false,
Expand Down Expand Up @@ -271,6 +272,42 @@ describe('FAQ Overview Functions', () => {
expect(fetchAllFaqsByCategory).toHaveBeenCalledWith('3', 'en', true, true);
});

it('should render the active state read-only without the publish permission', async () => {
document.body.innerHTML = `
<div class="accordion-collapse" data-pmf-categoryId="3" data-pmf-language="en"></div>
<table><tbody id="tbody-category-id-3" data-pmf-csrf="csrf-token"></tbody></table>
`;

(fetchAllFaqsByCategory as Mock).mockResolvedValue({
faqs: [
{
id: 7,
language: 'en',
solution_id: 1007,
question: 'Test FAQ',
created: '2026-03-01',
category_id: 3,
sticky: 'no',
active: 'yes',
isAllowedToPublish: false,
},
],
isAllowedToTranslate: false,
});

await handleFaqOverview();

const category = document.querySelector('.accordion-collapse') as HTMLElement;
category.dispatchEvent(new Event('shown.bs.collapse'));

await flushPromises();

// No checkbox to click, but the state stays visible, and the sticky toggle is unaffected.
expect(document.querySelector('.pmf-admin-active-faq')).toBeNull();
expect(document.querySelector('.bi-check-lg')).not.toBeNull();
expect(document.querySelector('.pmf-admin-sticky-faq')).not.toBeNull();
});

it('should call saveStatus when sticky toggle changes', async () => {
document.body.innerHTML = `
<div class="accordion-collapse" data-pmf-categoryId="3" data-pmf-language="en"></div>
Expand All @@ -288,6 +325,7 @@ describe('FAQ Overview Functions', () => {
category_id: 3,
sticky: 'no',
active: 'yes',
isAllowedToPublish: true,
},
],
isAllowedToTranslate: false,
Expand Down Expand Up @@ -342,6 +380,7 @@ describe('FAQ Overview Functions', () => {
category_id: 3,
sticky: 'no',
active: 'yes',
isAllowedToPublish: true,
},
],
isAllowedToTranslate: false,
Expand Down Expand Up @@ -394,6 +433,7 @@ describe('FAQ Overview Functions', () => {
category_id: 3,
sticky: 'no',
active: 'yes',
isAllowedToPublish: true,
},
],
isAllowedToTranslate: false,
Expand Down Expand Up @@ -439,6 +479,7 @@ describe('FAQ Overview Functions', () => {
category_id: 3,
sticky: 'no',
active: 'yes',
isAllowedToPublish: true,
},
],
isAllowedToTranslate: false,
Expand Down Expand Up @@ -481,6 +522,7 @@ describe('FAQ Overview Functions', () => {
category_id: 3,
sticky: 'no',
active: 'yes',
isAllowedToPublish: true,
},
],
isAllowedToTranslate: false,
Expand Down
33 changes: 21 additions & 12 deletions phpmyfaq/admin/assets/src/content/faqs.overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,19 +266,28 @@ const populateCategoryTable = async (categoryId: string, faqs: Faq[], isAllowedT
}),
])
);
// Without the publish right for every category of this FAQ the checkbox would only ever
// produce a 403, so show the state read-only instead of offering an action the API rejects.
row.append(
addElement('td', { classList: 'align-middle' }, [
addElement('input', {
classList: 'form-check-input pmf-admin-active-faq',
type: 'checkbox',
'data-pmf-category-id-active': faq.category_id.toString(),
'data-pmf-faq-id': faq.id.toString(),
'data-pmf-csrf': csrfToken,
lang: faq.language,
id: `active_record_${faq.category_id.toString()}_${faq.id.toString()}`,
checked: faq.active === 'yes',
}),
])
faq.isAllowedToPublish
? addElement('td', { classList: 'align-middle' }, [
addElement('input', {
classList: 'form-check-input pmf-admin-active-faq',
type: 'checkbox',
'data-pmf-category-id-active': faq.category_id.toString(),
'data-pmf-faq-id': faq.id.toString(),
'data-pmf-csrf': csrfToken,
lang: faq.language,
id: `active_record_${faq.category_id.toString()}_${faq.id.toString()}`,
checked: faq.active === 'yes',
}),
])
: addElement('td', { classList: 'align-middle' }, [
addElement('i', {
classList: faq.active === 'yes' ? 'bi bi-check-lg text-success' : 'bi bi-x-lg text-muted',
'aria-hidden': 'true',
}),
])
);
row.append(
addElement('td', { classList: 'align-middle text-center' }, [
Expand Down
1 change: 1 addition & 0 deletions phpmyfaq/admin/assets/src/interfaces/FaqList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface Faq {
updated: string; // Format: YYYYMMDDHHMMSS
visits: number;
created: string; // Format: YYYY-MM-DD HH:MM:SS
isAllowedToPublish: boolean;
}

export interface FaqList {
Expand Down
13 changes: 6 additions & 7 deletions phpmyfaq/assets/templates/admin/content/faq.editor.twig
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,7 @@
{{ ad_entry_status }}
</h5>
<!-- active or not -->
{% if hasPermissionForApprove %}
{% if hasPermissionForPublish %}
<div class="form-check">
<input type="radio" id="active" name="active" value="yes" class="form-check-input" {{ isActive }}>
<label class="form-check-label" for="active">
Expand All @@ -599,12 +599,11 @@
</label>
</div>
{% else %}
<div class="form-check">
<input type="radio" id="inactive" name="active" value="no" class="form-check-input" checked>
<label class="form-check-label" for="inactive">
{{ ad_entry_not_visibility }}
</label>
</div>
{# No "active" field is submitted, so saving preserves the stored publication state. #}
<p class="form-text mb-0">
{{ isActive ? ad_entry_visibility : ad_entry_not_visibility }}
<span class="text-muted d-block">{{ msgNoPublishPermission }}</span>
</p>
{% endif %}
</div>

Expand Down
25 changes: 19 additions & 6 deletions phpmyfaq/src/phpMyFAQ/Administration/Faq.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ public function __construct(

/**
* Get all FAQs by category
*
* @return list<array{
* id: int,
* language: string,
* solution_id: int,
* active: string,
* sticky: string,
* category_id: int,
* question: string,
* updated: string,
* visits: int,
* created: string,
* }>
*/
public function getAllFaqsByCategory(int $categoryId, bool $onlyInactive = false, bool $onlyNew = false): array
{
Expand Down Expand Up @@ -102,15 +115,15 @@ public function getAllFaqsByCategory(int $categoryId, bool $onlyInactive = false

$faqData[] = [
'id' => (int) $row->id,
'language' => $row->lang,
'language' => (string) $row->lang,
'solution_id' => (int) $row->solution_id,
'active' => $row->active,
'active' => (string) $row->active,
'sticky' => $row->sticky ? 'yes' : 'no',
'category_id' => (int) $row->category_id,
'question' => $row->question,
'updated' => $row->updated,
'visits' => (int) $visits,
'created' => $row->created,
'question' => (string) $row->question,
'updated' => (string) $row->updated,
'visits' => $visits,
'created' => (string) $row->created,
];
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public function getLanguagesToTranslate(Configuration $configuration, int $categ

$result = [];
foreach (LanguageHelper::getAvailableLanguages() as $lang => $name) {
$langLower = strtolower((string) $lang);
$langLower = strtolower($lang);
if (!in_array($langLower, $existingLower, strict: true)) {
$result[$langLower] = $name;
}
Expand Down
39 changes: 39 additions & 0 deletions phpmyfaq/src/phpMyFAQ/Category/Relation.php
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,45 @@ private function aggregateRecursively(
return $total;
}

/**
* Returns the category ids of many records at once, keyed by record id — listing pages
* would otherwise issue one getCategories() query per FAQ.
*
* @param int[] $faqIds
* @return array<int, list<int>>
*/
public function getCategoryIdsForRecords(array $faqIds, string $faqLang): array
{
$faqIds = array_values(array_unique(array_filter($faqIds, static fn(int $faqId): bool => $faqId > 0)));

if ($faqIds === []) {
return [];
}

$query = sprintf(
'SELECT record_id, category_id FROM %sfaqcategoryrelations WHERE record_id IN (%s)'
. ' AND record_lang = ?',
Database::getTablePrefix(),
implode(', ', array_fill(start_index: 0, count: count($faqIds), value: '?')),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

$categoryIdsByFaq = [];
$result = $this->configuration->getDb()->queryPrepared($query, [...$faqIds, $faqLang]);

if ($result) {
while (true) {
$row = $this->configuration->getDb()->fetchObject($result);
if (!$row instanceof \stdClass) {
break;
}

$categoryIdsByFaq[(int) $row->record_id][] = (int) $row->category_id;
}
}

return $categoryIdsByFaq;
}

/**
* Returns the categories from a FAQ id and language.
*
Expand Down
Loading
Loading