From d6fbaa802c97b003d3febf0c6e77aa3091888c22 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 10 Aug 2026 18:13:41 +0200 Subject: [PATCH 1/2] feat: added language-scoped permissions for managing FAQs, closes #4312 --- docs/administration.md | 21 + phpmyfaq/admin/assets/src/api/group.test.ts | 92 ++++ phpmyfaq/admin/assets/src/api/group.ts | 53 ++- phpmyfaq/admin/assets/src/api/user.test.ts | 43 ++ phpmyfaq/admin/assets/src/api/user.ts | 40 +- .../admin/assets/src/group/groups.test.ts | 35 +- phpmyfaq/admin/assets/src/group/groups.ts | 186 +++++++- phpmyfaq/admin/assets/src/interfaces/Group.ts | 9 + phpmyfaq/admin/assets/src/user/users.test.ts | 37 ++ phpmyfaq/admin/assets/src/user/users.ts | 192 +++++++- .../assets/templates/admin/user/group.twig | 24 + .../assets/templates/admin/user/user.twig | 24 + .../Controller/AbstractController.php | 27 ++ .../Administration/Api/FaqController.php | 26 +- .../Administration/Api/GroupController.php | 99 ++++ .../Administration/Api/UserController.php | 100 ++++ .../Administration/FaqController.php | 34 +- .../Administration/GroupController.php | 3 + .../Administration/UserController.php | 3 + .../Language/LanguageRestrictionFilter.php | 65 +++ .../phpMyFAQ/Permission/BasicPermission.php | 84 ++++ .../LanguagePermissionRepository.php | 429 ++++++++++++++++++ .../phpMyFAQ/Permission/MediumPermission.php | 128 ++++++ .../Permission/PermissionInterface.php | 24 + .../Setup/Installation/DatabaseSchema.php | 22 + .../Setup/Migration/MigrationRegistry.php | 1 + .../Migration/Versions/Migration420Alpha2.php | 158 +++++++ phpmyfaq/translations/language_en.php | 13 + .../Administration/AdminMenuBuilderTest.php | 10 + .../Attachment/AttachmentServiceTest.php | 10 + .../Administration/Api/FaqControllerTest.php | 246 ++++++++++ .../Api/GroupControllerTest.php | 125 +++++ .../Administration/Api/UserControllerTest.php | 170 +++++++ .../Administration/FaqControllerTest.php | 47 ++ .../LanguageRestrictionFilterTest.php | 50 ++ .../Permission/BasicPermissionTest.php | 48 ++ .../LanguagePermissionRepositoryTest.php | 321 +++++++++++++ .../Permission/MediumPermissionTest.php | 274 +++++++++++ .../Setup/Installation/DatabaseSchemaTest.php | 4 +- .../Installation/SchemaInstallerTest.php | 2 +- .../Setup/Migration/MigrationRegistryTest.php | 6 +- 41 files changed, 3263 insertions(+), 22 deletions(-) create mode 100644 phpmyfaq/src/phpMyFAQ/Language/LanguageRestrictionFilter.php create mode 100644 phpmyfaq/src/phpMyFAQ/Permission/LanguagePermissionRepository.php create mode 100644 phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha2.php create mode 100644 tests/phpMyFAQ/Language/LanguageRestrictionFilterTest.php create mode 100644 tests/phpMyFAQ/Permission/LanguagePermissionRepositoryTest.php diff --git a/docs/administration.md b/docs/administration.md index 3c2cc9f035..40a48cab61 100644 --- a/docs/administration.md +++ b/docs/administration.md @@ -73,6 +73,27 @@ Groups can be restricted per right to a set of categories (Admin → Groups → - A blocked action returns HTTP 403 with a message naming the missing right and the category. - FAQs without any category assignment can only be modified by users whose rights are not category-restricted. +### 5.1.4 Language restrictions + +Both individual users and groups can be restricted per right to a set of languages (Admin → Users/Groups → Language +restrictions). The rules are deliberately different from category restrictions: + +- **No restriction selected = the right applies to all languages.** +- Unlike category restrictions, **language restrictions apply to direct user rights too** — a user's own right grant can + itself be scoped to specific language(s), not only rights granted through group membership. +- A user restricted to `de` cannot add, edit, translate, approve, or delete FAQ content in `fr` or any other language, + and the admin UI only offers the allowed languages. +- **Basic permission mode has no groups but still enforces per-user language restrictions** directly (unlike category + restrictions, which are a no-op in Basic mode). +- When a user has both a direct grant and a group grant for the same right, the right applies to the **union** of both + grants' allowed languages; either grant being unrestricted makes the right globally usable for that user. +- Restrictions match exact language codes; there is no fallback or inheritance between language variants. +- Translating a FAQ into a new language is checked against the **target** language (the language being created), not + the source language being read. +- A blocked action returns HTTP 403 with a message naming the missing right and the language. +- Category and language restrictions combine with **AND** semantics: an action must be permitted by both the category + and the language rules to succeed. + ## 5.2 Content ### 5.2.1 Category Administration diff --git a/phpmyfaq/admin/assets/src/api/group.test.ts b/phpmyfaq/admin/assets/src/api/group.test.ts index 4a62f9e106..6f19bc45d7 100644 --- a/phpmyfaq/admin/assets/src/api/group.test.ts +++ b/phpmyfaq/admin/assets/src/api/group.test.ts @@ -8,6 +8,9 @@ import { fetchGroupCategoryRestrictions, saveGroupCategoryRestrictions, fetchCategoriesForRestrictions, + fetchGroupLanguageRestrictions, + saveGroupLanguageRestrictions, + fetchLanguagesForRestrictions, updateGroup, updateGroupMembers, updateGroupPermissions, @@ -268,6 +271,95 @@ describe('fetchCategoriesForRestrictions', () => { }); }); +describe('fetchGroupLanguageRestrictions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should fetch language restrictions for a group', async () => { + const mockResponse = { '1': ['en', 'de'], '3': ['fr'] }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const groupId = '5'; + const result = await fetchGroupLanguageRestrictions(groupId); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith(`./api/group/language-restrictions/${groupId}`, { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + it('should throw an error if the network response is not ok', async () => { + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.')); + + await expect(fetchGroupLanguageRestrictions('5')).rejects.toThrow('Network response was not ok.'); + }); +}); + +describe('saveGroupLanguageRestrictions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should save language restrictions for a group right', async () => { + const mockResponse = { ok: true, status: 200 } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponse); + + const result = await saveGroupLanguageRestrictions('5', '1', ['en', 'de'], 'test-csrf-token'); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith('./api/group/language-restrictions', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ groupId: 5, rightId: 1, languages: ['en', 'de'], csrfToken: 'test-csrf-token' }), + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); +}); + +describe('fetchLanguagesForRestrictions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should fetch all languages for restriction picker', async () => { + const mockResponse = [ + { code: 'en', label: 'English' }, + { code: 'de', label: 'Deutsch' }, + ]; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const result = await fetchLanguagesForRestrictions(); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/group/languages', { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + it('should throw an error if the network response is not ok', async () => { + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.')); + + await expect(fetchLanguagesForRestrictions()).rejects.toThrow('Network response was not ok.'); + }); +}); + describe('updateGroup', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/phpmyfaq/admin/assets/src/api/group.ts b/phpmyfaq/admin/assets/src/api/group.ts index 96ae384307..ec6c67a4e8 100644 --- a/phpmyfaq/admin/assets/src/api/group.ts +++ b/phpmyfaq/admin/assets/src/api/group.ts @@ -13,7 +13,16 @@ * @since 2023-01-02 */ -import { ApiResponse, CategoryItem, CategoryRestrictions, Group, Member, User } from '../interfaces'; +import { + ApiResponse, + CategoryItem, + CategoryRestrictions, + Group, + LanguageItem, + LanguageRestrictions, + Member, + User, +} from '../interfaces'; import { fetchJson, fetchWrapper } from './fetch-wrapper'; export const fetchAllGroups = async (): Promise => { @@ -118,6 +127,48 @@ export const fetchCategoriesForRestrictions = async (): Promise })) as CategoryItem[]; }; +export const fetchGroupLanguageRestrictions = async (groupId: string): Promise => { + return (await fetchJson(`./api/group/language-restrictions/${groupId}`, { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + })) as LanguageRestrictions; +}; + +export const saveGroupLanguageRestrictions = async ( + groupId: string, + rightId: string, + languages: string[], + csrfToken: string +): Promise => { + return await fetchWrapper('./api/group/language-restrictions', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ groupId: parseInt(groupId), rightId: parseInt(rightId), languages, csrfToken }), + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); +}; + +export const fetchLanguagesForRestrictions = async (): Promise => { + return (await fetchJson('./api/group/languages', { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + })) as LanguageItem[]; +}; + export const updateGroup = async ( groupId: string, name: string, diff --git a/phpmyfaq/admin/assets/src/api/user.test.ts b/phpmyfaq/admin/assets/src/api/user.test.ts index dcb911211b..635bc443d8 100644 --- a/phpmyfaq/admin/assets/src/api/user.test.ts +++ b/phpmyfaq/admin/assets/src/api/user.test.ts @@ -4,9 +4,11 @@ import { addUser, fetchUsers, fetchUserData, + fetchUserLanguageRestrictions, fetchUserRights, fetchAllUsers, overwritePassword, + saveUserLanguageRestrictions, updateUserData, updateUserRights, deleteUser, @@ -267,6 +269,47 @@ describe('User API', () => { }); }); + describe('fetchUserLanguageRestrictions', () => { + it('should fetch language restrictions for a user', async () => { + const mockResponse = { '1': ['en', 'de'] }; + global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockResponse) } as Response)); + + const result = await fetchUserLanguageRestrictions('42'); + + expect(result).toEqual(mockResponse); + expect(global.fetch).toHaveBeenCalledWith('./api/user/language-restrictions/42', { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + }); + + describe('saveUserLanguageRestrictions', () => { + it('should PUT the language restrictions payload to user/language-restrictions', async () => { + const mockResponse = { success: 'saved' }; + global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockResponse) } as Response)); + + const result = await saveUserLanguageRestrictions('42', '1', ['en', 'de'], 'token'); + + expect(result).toEqual(mockResponse); + expect(global.fetch).toHaveBeenCalledWith('./api/user/language-restrictions', { + method: 'PUT', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ csrfToken: 'token', userId: '42', rightId: 1, languages: ['en', 'de'] }), + }); + }); + }); + describe('addUser', () => { it('should POST the new user payload to user/add', async () => { const mockResponse = { success: 'added' }; diff --git a/phpmyfaq/admin/assets/src/api/user.ts b/phpmyfaq/admin/assets/src/api/user.ts index 3d5d756ae8..a81c136bee 100644 --- a/phpmyfaq/admin/assets/src/api/user.ts +++ b/phpmyfaq/admin/assets/src/api/user.ts @@ -14,7 +14,15 @@ */ import { fetchJson } from './fetch-wrapper'; -import { AddUserPayload, ApiResponse, UserAutocomplete, UserData, UserEditPayload, UserOverview } from '../interfaces'; +import { + AddUserPayload, + ApiResponse, + LanguageRestrictions, + UserAutocomplete, + UserData, + UserEditPayload, + UserOverview, +} from '../interfaces'; // Dual-path endpoint: when a non-empty `filter` query param is sent (as here), // the server returns autocomplete pairs ({ label, value }); when no filter is @@ -118,6 +126,36 @@ export const updateUserRights = async ( }); }; +export const fetchUserLanguageRestrictions = async (userId: string): Promise => { + return await fetchJson(`./api/user/language-restrictions/${userId}`, { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); +}; + +export const saveUserLanguageRestrictions = async ( + userId: string, + rightId: string, + languages: string[], + csrfToken: string +): Promise => { + return await fetchJson('./api/user/language-restrictions', { + method: 'PUT', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ csrfToken, userId, rightId: parseInt(rightId), languages }), + }); +}; + export const addUser = async (payload: AddUserPayload): Promise => { return await fetchJson('./api/user/add', { method: 'POST', diff --git a/phpmyfaq/admin/assets/src/group/groups.test.ts b/phpmyfaq/admin/assets/src/group/groups.test.ts index bf627a1a57..0980ab4532 100644 --- a/phpmyfaq/admin/assets/src/group/groups.test.ts +++ b/phpmyfaq/admin/assets/src/group/groups.test.ts @@ -8,7 +8,10 @@ import { fetchCategoriesForRestrictions, fetchGroup, fetchGroupCategoryRestrictions, + fetchGroupLanguageRestrictions, fetchGroupRights, + fetchLanguagesForRestrictions, + saveGroupLanguageRestrictions, updateGroup, updateGroupMembers, updateGroupPermissions, @@ -76,6 +79,9 @@ const setupFullDom = (): void => {
+
+
@@ -103,6 +109,9 @@ const mockDefaultApis = (): void => { (fetchGroupRights as Mock).mockResolvedValue(['1', '3']); (fetchCategoriesForRestrictions as Mock).mockResolvedValue([]); (fetchGroupCategoryRestrictions as Mock).mockResolvedValue({}); + (fetchLanguagesForRestrictions as Mock).mockResolvedValue([]); + (fetchGroupLanguageRestrictions as Mock).mockResolvedValue({}); + (saveGroupLanguageRestrictions as Mock).mockResolvedValue({ ok: true } as Response); (updateGroup as Mock).mockResolvedValue({ success: 'saved' }); (updateGroupMembers as Mock).mockResolvedValue({ success: 'saved' }); (updateGroupPermissions as Mock).mockResolvedValue({ success: 'saved' }); @@ -345,15 +354,21 @@ describe('handleGroups', () => { expect(document.getElementById('pmf-group-empty-state')?.classList.contains('d-none')).toBe(false); }); - // Keep this test last: cachedCategories is module-level and, once populated - // with a non-empty list, is reused by any test that runs after it. - it('should indent subcategories in the category restrictions options', async () => { + // Keep this test last: cachedCategories and cachedLanguages are module-level + // and, once populated with a non-empty list, are reused by any test that + // runs after it. + it('should indent subcategories in the category restrictions options and render language restriction options', async () => { setupFullDom(); mockDefaultApis(); (fetchCategoriesForRestrictions as Mock).mockResolvedValue([ { id: 1, name: 'Guides', parent_id: 0, level: 0 }, { id: 2, name: 'Setup', parent_id: 1, level: 1 }, ]); + (fetchLanguagesForRestrictions as Mock).mockResolvedValue([ + { code: 'en', label: 'English' }, + { code: 'de', label: 'Deutsch' }, + ]); + (fetchGroupLanguageRestrictions as Mock).mockResolvedValue({ '1': ['de'] }); await handleGroups(); await selectFirstGroup(); @@ -365,5 +380,19 @@ describe('handleGroups', () => { expect(options.length).toBe(2); expect(options[0].textContent).toBe('Guides'); expect(options[1].textContent).toBe('\u00A0\u00A0\u00A0Setup'); + + const languageOptions = document.querySelectorAll( + '#languageRestrictionsBody select[data-right-id="1"] option' + ); + expect(languageOptions.length).toBe(2); + expect(languageOptions[0].textContent).toBe('English'); + expect(languageOptions[1].selected).toBe(true); + + (document.getElementById('saveLanguageRestrictions') as HTMLButtonElement).click(); + await flushPromises(); + + expect(saveGroupLanguageRestrictions).toHaveBeenCalledWith('1', '1', ['de'], 'csrf-language-restrictions'); + expect(saveGroupLanguageRestrictions).toHaveBeenCalledWith('1', '3', [], 'csrf-language-restrictions'); + expect(pushNotification).toHaveBeenCalledWith('Language restrictions saved.'); }); }); diff --git a/phpmyfaq/admin/assets/src/group/groups.ts b/phpmyfaq/admin/assets/src/group/groups.ts index 9c55fad929..4539d8c763 100644 --- a/phpmyfaq/admin/assets/src/group/groups.ts +++ b/phpmyfaq/admin/assets/src/group/groups.ts @@ -22,14 +22,25 @@ import { fetchCategoriesForRestrictions, fetchGroup, fetchGroupCategoryRestrictions, + fetchGroupLanguageRestrictions, fetchGroupRights, + fetchLanguagesForRestrictions, saveGroupCategoryRestrictions, + saveGroupLanguageRestrictions, updateGroup, updateGroupMembers, updateGroupPermissions, } from '../api'; import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; -import { ApiResponse, CategoryItem, CategoryRestrictions, Group, User } from '../interfaces'; +import { + ApiResponse, + CategoryItem, + CategoryRestrictions, + Group, + LanguageItem, + LanguageRestrictions, + User, +} from '../interfaces'; let allUsers: User[] = []; let selectedGroupId: string = ''; @@ -139,6 +150,26 @@ const selectGroup = async (groupId: string): Promise => { if (requestToken !== selectRequestToken) { return; } + try { + await loadLanguageRestrictions(groupId); + } catch (error) { + if (requestToken !== selectRequestToken) { + return; + } + console.error('Failed to load language restrictions:', error); + currentLanguageRestrictions = {}; + const container = document.getElementById('languageRestrictionsBody'); + if (container) { + container.innerHTML = ''; + const errorParagraph = document.createElement('p'); + errorParagraph.className = 'text-body-secondary'; + errorParagraph.textContent = container.dataset.msgEmpty || 'No permissions assigned to this group.'; + container.appendChild(errorParagraph); + } + } + if (requestToken !== selectRequestToken) { + return; + } (document.getElementById('pmf-group-empty-state') as HTMLElement).classList.add('d-none'); (document.getElementById('pmf-group-detail') as HTMLElement).classList.remove('d-none'); @@ -279,6 +310,7 @@ const wirePermissionToggles = (): void => { checkbox.checked = checked; }); refreshRestrictionsPanel(); + refreshLanguageRestrictionsPanel(); }; (document.getElementById('pmf-group-check-all') as HTMLButtonElement).addEventListener('click', (): void => { @@ -292,6 +324,7 @@ const wirePermissionToggles = (): void => { const target = event.target as HTMLInputElement; if (target.type === 'checkbox' && target.classList.contains('permission')) { refreshRestrictionsPanel(); + refreshLanguageRestrictionsPanel(); } }); }; @@ -304,6 +337,14 @@ const refreshRestrictionsPanel = (): void => { } }; +const refreshLanguageRestrictionsPanel = (): void => { + const container = document.getElementById('languageRestrictionsBody'); + if (container) { + captureCurrentLanguageRestrictions(container); + renderLanguageRestrictions(container); + } +}; + const notifyResult = (response: ApiResponse): void => { if (response.success) { pushNotification(response.success); @@ -396,6 +437,18 @@ const wireSaveButtons = (): void => { } } ); + + (document.getElementById('saveLanguageRestrictions') as HTMLButtonElement).addEventListener( + 'click', + async (event: Event): Promise => { + event.preventDefault(); + try { + await handleLanguageRestrictionsSave(); + } catch { + pushErrorNotification(getGenericErrorMessage()); + } + } + ); }; const wireDeleteModal = (): void => { @@ -569,3 +622,134 @@ export const handleCategoryRestrictionsSave = async (): Promise => { pushNotification(container.dataset.msgSaved || 'Category restrictions saved.'); } }; + +let cachedLanguages: LanguageItem[] = []; +let currentLanguageRestrictions: LanguageRestrictions = {}; + +const loadLanguageRestrictions = async (groupId: string): Promise => { + const container = document.getElementById('languageRestrictionsBody'); + if (!container) { + return; + } + + if (cachedLanguages.length === 0) { + cachedLanguages = await fetchLanguagesForRestrictions(); + } + + currentLanguageRestrictions = await fetchGroupLanguageRestrictions(groupId); + + renderLanguageRestrictions(container); +}; + +const captureCurrentLanguageRestrictions = (container: HTMLElement): void => { + const selects = container.querySelectorAll('select[data-right-id]'); + selects.forEach((select: HTMLSelectElement): void => { + const rightId = select.dataset.rightId; + if (!rightId) { + return; + } + currentLanguageRestrictions[rightId] = [...select.options] + .filter((option: HTMLOptionElement): boolean => option.selected) + .map((option: HTMLOptionElement): string => option.value); + }); +}; + +const renderLanguageRestrictions = (container: HTMLElement): void => { + const checkedRights = document.querySelectorAll( + '#pmf-permission-list input[type=checkbox]:checked' + ); + + container.innerHTML = ''; + + if (checkedRights.length === 0) { + const emptyMsg = container.dataset.msgEmpty || 'No permissions assigned to this group.'; + const emptyParagraph = document.createElement('p'); + emptyParagraph.className = 'text-body-secondary'; + emptyParagraph.textContent = emptyMsg; + container.appendChild(emptyParagraph); + return; + } + + checkedRights.forEach((checkbox: HTMLInputElement): void => { + const rightId = checkbox.value; + const label = checkbox.closest('.form-check')?.querySelector('label')?.textContent?.trim() || `Right ${rightId}`; + const restrictedLanguageCodes = currentLanguageRestrictions[rightId] || []; + + const wrapper = document.createElement('div'); + wrapper.className = 'mb-3'; + + const labelElement = document.createElement('label'); + labelElement.className = 'form-label fw-semibold'; + labelElement.textContent = label; + wrapper.appendChild(labelElement); + + const select = document.createElement('select'); + select.className = 'form-select form-select-sm'; + select.multiple = true; + select.size = 4; + select.dataset.rightId = rightId; + + cachedLanguages.forEach((lang: LanguageItem): void => { + const option = document.createElement('option'); + option.value = lang.code; + option.textContent = lang.label; + option.selected = restrictedLanguageCodes.includes(lang.code); + select.appendChild(option); + }); + + wrapper.appendChild(select); + + const helpText = document.createElement('div'); + helpText.className = 'form-text'; + helpText.textContent = + container.dataset.msgHelp || 'Select languages to restrict this permission. Leave empty for unrestricted access.'; + wrapper.appendChild(helpText); + + container.appendChild(wrapper); + }); +}; + +export const handleLanguageRestrictionsSave = async (): Promise => { + if (selectedGroupId === '') { + return; + } + + const container = document.getElementById('languageRestrictionsBody'); + if (!container) { + return; + } + + const csrfToken = container.dataset.csrfToken || ''; + + // Collect every right ID from the permission checkboxes so unticked + // permissions also get their stored restrictions cleared. + const rightIds = new Set(); + document + .querySelectorAll('#pmf-permission-list input[type=checkbox].permission') + .forEach((checkbox: HTMLInputElement): void => { + if (checkbox.value) { + rightIds.add(checkbox.value); + } + }); + + let failed = false; + for (const rightId of rightIds) { + const select = container.querySelector(`select[data-right-id="${rightId}"]`); + const selectedLanguageCodes = select + ? [...select.options] + .filter((option: HTMLOptionElement): boolean => option.selected) + .map((option: HTMLOptionElement): string => option.value) + : []; + + const response = await saveGroupLanguageRestrictions(selectedGroupId, rightId, selectedLanguageCodes, csrfToken); + if (!response.ok) { + failed = true; + } + } + + if (failed) { + pushErrorNotification(container.dataset.msgSaveFailed || 'Failed to save language restrictions.'); + } else { + pushNotification(container.dataset.msgSaved || 'Language restrictions saved.'); + } +}; diff --git a/phpmyfaq/admin/assets/src/interfaces/Group.ts b/phpmyfaq/admin/assets/src/interfaces/Group.ts index 72bda3f2a1..60d4973de2 100644 --- a/phpmyfaq/admin/assets/src/interfaces/Group.ts +++ b/phpmyfaq/admin/assets/src/interfaces/Group.ts @@ -15,3 +15,12 @@ export interface CategoryItem { export interface CategoryRestrictions { [rightId: string]: number[]; } + +export interface LanguageItem { + code: string; + label: string; +} + +export interface LanguageRestrictions { + [rightId: string]: string[]; +} diff --git a/phpmyfaq/admin/assets/src/user/users.test.ts b/phpmyfaq/admin/assets/src/user/users.test.ts index 1bf3dc47a1..fcc7a9df03 100644 --- a/phpmyfaq/admin/assets/src/user/users.test.ts +++ b/phpmyfaq/admin/assets/src/user/users.test.ts @@ -3,10 +3,13 @@ import { handleUsers } from './users'; import { deleteUser, fetchAllUsers, + fetchLanguagesForRestrictions, fetchUserData, + fetchUserLanguageRestrictions, fetchUserRights, fetchUsers, overwritePassword, + saveUserLanguageRestrictions, updateUserData, updateUserRights, } from '../api'; @@ -46,6 +49,7 @@ const setupFullDom = (userId = ''): void => {
@@ -79,6 +83,9 @@ const setupFullDom = (userId = ''): void => {
+
+
@@ -134,6 +141,9 @@ const mockDefaultApis = (): void => { (fetchUsers as Mock).mockResolvedValue([{ label: 'alice', value: 10 }]); (fetchUserData as Mock).mockResolvedValue(aliceData); (fetchUserRights as Mock).mockResolvedValue(['1']); + (fetchLanguagesForRestrictions as Mock).mockResolvedValue([]); + (fetchUserLanguageRestrictions as Mock).mockResolvedValue({}); + (saveUserLanguageRestrictions as Mock).mockResolvedValue({ success: 'saved' }); (updateUserData as Mock).mockResolvedValue({ success: 'saved' }); (updateUserRights as Mock).mockResolvedValue({ success: 'saved' }); (deleteUser as Mock).mockResolvedValue({ success: 'deleted' }); @@ -395,6 +405,33 @@ describe('handleUsers', () => { expect(pushNotification).toHaveBeenCalledWith('saved'); }); + it('should render and save per-right language restrictions', async () => { + setupFullDom(); + mockDefaultApis(); + (fetchLanguagesForRestrictions as Mock).mockResolvedValue([ + { code: 'en', label: 'English' }, + { code: 'de', label: 'Deutsch' }, + ]); + (fetchUserLanguageRestrictions as Mock).mockResolvedValue({ '1': ['de'] }); + + await handleUsers(); + await selectFirstUser(); + + // fetchUserRights checks right 1, so its restriction select renders. + const options = document.querySelectorAll( + '#userLanguageRestrictionsBody select[data-right-id="1"] option' + ); + expect(options.length).toBe(2); + expect(options[0].textContent).toBe('English'); + expect(options[1].selected).toBe(true); + + (document.getElementById('pmf-user-language-restrictions-save') as HTMLButtonElement).click(); + await flushPromises(); + + expect(saveUserLanguageRestrictions).toHaveBeenCalledWith('10', '1', ['de'], 'csrf-language-restrictions'); + expect(pushNotification).toHaveBeenCalledWith('Language restrictions saved.'); + }); + it('should filter the permission list by label', async () => { setupFullDom(); mockDefaultApis(); diff --git a/phpmyfaq/admin/assets/src/user/users.ts b/phpmyfaq/admin/assets/src/user/users.ts index f8455ed3f4..3a82e23a7f 100644 --- a/phpmyfaq/admin/assets/src/user/users.ts +++ b/phpmyfaq/admin/assets/src/user/users.ts @@ -17,15 +17,25 @@ import { Modal } from 'bootstrap'; import { deleteUser, fetchAllUsers, + fetchLanguagesForRestrictions, fetchUserData, + fetchUserLanguageRestrictions, fetchUserRights, fetchUsers, overwritePassword, + saveUserLanguageRestrictions, updateUserData, updateUserRights, } from '../api'; import { capitalize, pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; -import { ApiResponse, UserAutocomplete, UserData, UserOverview } from '../interfaces'; +import { + ApiResponse, + LanguageItem, + LanguageRestrictions, + UserAutocomplete, + UserData, + UserOverview, +} from '../interfaces'; import { wireAddUserModal } from './add-user'; interface UserListEntry { @@ -207,6 +217,26 @@ const selectUser = async (userId: string): Promise => { if (requestToken !== selectRequestToken) { return; } + try { + await loadUserLanguageRestrictions(userId); + } catch (error) { + if (requestToken !== selectRequestToken) { + return; + } + console.error('Failed to load language restrictions:', error); + currentLanguageRestrictions = {}; + const container = document.getElementById('userLanguageRestrictionsBody'); + if (container) { + container.innerHTML = ''; + const errorParagraph = document.createElement('p'); + errorParagraph.className = 'text-body-secondary'; + errorParagraph.textContent = container.dataset.msgEmpty || 'No permissions assigned to this user.'; + container.appendChild(errorParagraph); + } + } + if (requestToken !== selectRequestToken) { + return; + } (document.getElementById('pmf-user-empty-state') as HTMLElement).classList.add('d-none'); (document.getElementById('pmf-user-detail') as HTMLElement).classList.remove('d-none'); @@ -277,6 +307,7 @@ const wirePermissionToggles = (): void => { .forEach((checkbox: HTMLInputElement): void => { checkbox.checked = checked; }); + refreshUserLanguageRestrictionsPanel(); }; (document.getElementById('pmf-user-check-all') as HTMLButtonElement).addEventListener('click', (): void => { @@ -285,6 +316,21 @@ const wirePermissionToggles = (): void => { (document.getElementById('pmf-user-uncheck-all') as HTMLButtonElement).addEventListener('click', (): void => { setAll(false); }); + + document.getElementById('pmf-user-permission-list')?.addEventListener('change', (event: Event): void => { + const target = event.target as HTMLInputElement; + if (target.type === 'checkbox' && target.classList.contains('permission')) { + refreshUserLanguageRestrictionsPanel(); + } + }); +}; + +const refreshUserLanguageRestrictionsPanel = (): void => { + const container = document.getElementById('userLanguageRestrictionsBody'); + if (container) { + captureCurrentUserLanguageRestrictions(container); + renderUserLanguageRestrictions(container); + } }; const notifyResult = (response: ApiResponse): void => { @@ -344,6 +390,18 @@ const wireSaveButtons = (): void => { } } ); + + (document.getElementById('pmf-user-language-restrictions-save') as HTMLButtonElement).addEventListener( + 'click', + async (event: Event): Promise => { + event.preventDefault(); + try { + await handleUserLanguageRestrictionsSave(); + } catch { + pushErrorNotification(getGenericErrorMessage()); + } + } + ); }; const wireDeleteModal = (): void => { @@ -418,3 +476,135 @@ const wirePasswordOverwrite = (): void => { } }); }; + +let cachedLanguages: LanguageItem[] = []; +let currentLanguageRestrictions: LanguageRestrictions = {}; + +const loadUserLanguageRestrictions = async (userId: string): Promise => { + const container = document.getElementById('userLanguageRestrictionsBody'); + if (!container) { + return; + } + + if (cachedLanguages.length === 0) { + cachedLanguages = await fetchLanguagesForRestrictions(); + } + + currentLanguageRestrictions = await fetchUserLanguageRestrictions(userId); + + renderUserLanguageRestrictions(container); +}; + +const captureCurrentUserLanguageRestrictions = (container: HTMLElement): void => { + const selects = container.querySelectorAll('select[data-right-id]'); + selects.forEach((select: HTMLSelectElement): void => { + const rightId = select.dataset.rightId; + if (!rightId) { + return; + } + currentLanguageRestrictions[rightId] = [...select.options] + .filter((option: HTMLOptionElement): boolean => option.selected) + .map((option: HTMLOptionElement): string => option.value); + }); +}; + +const renderUserLanguageRestrictions = (container: HTMLElement): void => { + const checkedRights = document.querySelectorAll( + '#pmf-user-permission-list input[type=checkbox]:checked' + ); + + container.innerHTML = ''; + + if (checkedRights.length === 0) { + const emptyMsg = container.dataset.msgEmpty || 'No permissions assigned to this user.'; + const emptyParagraph = document.createElement('p'); + emptyParagraph.className = 'text-body-secondary'; + emptyParagraph.textContent = emptyMsg; + container.appendChild(emptyParagraph); + return; + } + + checkedRights.forEach((checkbox: HTMLInputElement): void => { + const rightId = checkbox.value; + const label = checkbox.closest('.form-check')?.querySelector('label')?.textContent?.trim() || `Right ${rightId}`; + const restrictedLanguageCodes = currentLanguageRestrictions[rightId] || []; + + const wrapper = document.createElement('div'); + wrapper.className = 'mb-3'; + + const labelElement = document.createElement('label'); + labelElement.className = 'form-label fw-semibold'; + labelElement.textContent = label; + wrapper.appendChild(labelElement); + + const select = document.createElement('select'); + select.className = 'form-select form-select-sm'; + select.multiple = true; + select.size = 4; + select.dataset.rightId = rightId; + + cachedLanguages.forEach((lang: LanguageItem): void => { + const option = document.createElement('option'); + option.value = lang.code; + option.textContent = lang.label; + option.selected = restrictedLanguageCodes.includes(lang.code); + select.appendChild(option); + }); + + wrapper.appendChild(select); + + const helpText = document.createElement('div'); + helpText.className = 'form-text'; + helpText.textContent = + container.dataset.msgHelp || 'Select languages to restrict this permission. Leave empty for unrestricted access.'; + wrapper.appendChild(helpText); + + container.appendChild(wrapper); + }); +}; + +export const handleUserLanguageRestrictionsSave = async (): Promise => { + if (selectedUserId === '') { + return; + } + + const container = document.getElementById('userLanguageRestrictionsBody'); + if (!container) { + return; + } + + const detail = document.getElementById('pmf-user-detail') as HTMLElement; + const csrfToken = detail.dataset.csrfLanguageRestrictions || ''; + + // Collect every right ID from the permission checkboxes so unticked + // permissions also get their stored restrictions cleared. + const rightIds = new Set(); + document + .querySelectorAll('#pmf-user-permission-list input[type=checkbox].permission') + .forEach((checkbox: HTMLInputElement): void => { + if (checkbox.value) { + rightIds.add(checkbox.value); + } + }); + + let failed = false; + for (const rightId of rightIds) { + const select = container.querySelector(`select[data-right-id="${rightId}"]`); + const selectedLanguageCodes = select + ? [...select.options] + .filter((option: HTMLOptionElement): boolean => option.selected) + .map((option: HTMLOptionElement): string => option.value) + : []; + + const response = await saveUserLanguageRestrictions(selectedUserId, rightId, selectedLanguageCodes, csrfToken); + if (!response.success) { + failed = true; + } + } + + if (failed) { + pushErrorNotification(container.dataset.msgSaveFailed || 'Failed to save language restrictions.'); + } else { + pushNotification(container.dataset.msgSaved || 'Language restrictions saved.'); + } +}; diff --git a/phpmyfaq/assets/templates/admin/user/group.twig b/phpmyfaq/assets/templates/admin/user/group.twig index a1ef59998c..21d644c0e0 100644 --- a/phpmyfaq/assets/templates/admin/user/group.twig +++ b/phpmyfaq/assets/templates/admin/user/group.twig @@ -87,6 +87,13 @@ {{ 'ad_group_category_restrictions' | translate }} +
@@ -192,6 +199,23 @@
+ +
+
+

{{ 'ad_group_language_restrictions_help' | translate }}

+
+
+ +
+
diff --git a/phpmyfaq/assets/templates/admin/user/user.twig b/phpmyfaq/assets/templates/admin/user/user.twig index 04aa08f3a4..28c8cdc4a6 100644 --- a/phpmyfaq/assets/templates/admin/user/user.twig +++ b/phpmyfaq/assets/templates/admin/user/user.twig @@ -52,6 +52,7 @@
@@ -83,6 +84,13 @@ {{ 'ad_user_rights' | translate }} +
+
+
+

{{ 'ad_user_language_restrictions_help' | translate }}

+
+
+ +
+
+
currentUser->isLoggedIn()) { + throw new UnauthorizedHttpException(challenge: 'User is not authenticated.'); + } + + $currentUser = $this->currentUser; + if (!$currentUser?->perm->hasPermissionForLanguage( + $currentUser->getUserId(), + $permissionType->value, + $language, + )) { + throw new ForbiddenException(message: sprintf( + 'User has no "%s" permission for language "%s".', + $permissionType->name, + $language, + )); + } + } + /** * Grants access when the user owns at least one of the given permissions. * diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/FaqController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/FaqController.php index c7f5980222..9eb59ed6a8 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/FaqController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/FaqController.php @@ -126,6 +126,9 @@ public function create(Request $request): JsonResponse $this->userHasPermissionForCategories(PermissionType::FAQ_ADD, $categories); $language = Filter::filterVar($data->lang ?? '', FILTER_SANITIZE_SPECIAL_CHARS, ''); + + $this->userHasPermissionForLanguage(PermissionType::FAQ_ADD, $language); + $tags = Filter::filterVar($data->tags ?? '', FILTER_SANITIZE_SPECIAL_CHARS, ''); $active = Filter::filterVar($data->active ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no'); $sticky = Filter::filterVar($data->sticky ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no'); @@ -361,6 +364,7 @@ public function update(Request $request): JsonResponse $categoryRelation = new Relation($this->configuration, $category); $currentCategoryIds = array_keys($categoryRelation->getCategories($faqId, $faqLang)); $this->userHasPermissionForCategories(PermissionType::FAQ_EDIT, [...$categories, ...$currentCategoryIds]); + $this->userHasPermissionForLanguage(PermissionType::FAQ_EDIT, $faqLang); $tags = Filter::filterVar($data->tags ?? '', FILTER_SANITIZE_SPECIAL_CHARS, ''); $active = Filter::filterVar($data->active ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no'); @@ -557,6 +561,7 @@ public function listByCategory(Request $request): JsonResponse $language = Filter::filterVar($request->attributes->get(key: 'language'), FILTER_SANITIZE_SPECIAL_CHARS, ''); $this->userHasPermissionForCategories(PermissionType::FAQ_EDIT, [$categoryId]); + $this->userHasPermissionForLanguage(PermissionType::FAQ_EDIT, $language); $onlyInactive = Filter::filterVar( $request->query->get(key: 'only-inactive'), @@ -570,11 +575,17 @@ public function listByCategory(Request $request): JsonResponse return $this->json([ 'faqs' => $faq->getAllFaqsByCategory($categoryId, $onlyInactive, $onlyNew), - 'isAllowedToTranslate' => $this->currentUser?->perm->hasPermissionForCategory( - $this->currentUser->getUserId(), - PermissionType::FAQ_TRANSLATE->value, - $categoryId, - ), + 'isAllowedToTranslate' => + $this->currentUser?->perm->hasPermissionForCategory( + $this->currentUser->getUserId(), + PermissionType::FAQ_TRANSLATE->value, + $categoryId, + ) + && $this->currentUser?->perm->hasPermissionForLanguage( + $this->currentUser->getUserId(), + PermissionType::FAQ_TRANSLATE->value, + $language, + ), ], Response::HTTP_OK); } @@ -601,6 +612,8 @@ public function activate(Request $request): JsonResponse } if ($faqIds !== []) { + $this->userHasPermissionForLanguage(PermissionType::FAQ_APPROVE, $faqLanguage); + $activateCategory = new Category($this->configuration, [], withPermission: false); $activateCategoryRelation = new Relation($this->configuration, $activateCategory); foreach ($faqIds as $faqId) { @@ -655,6 +668,8 @@ public function sticky(Request $request): JsonResponse } if ($faqIds !== []) { + $this->userHasPermissionForLanguage(PermissionType::FAQ_EDIT, $faqLanguage); + $stickyCategory = new Category($this->configuration, [], withPermission: false); $stickyCategoryRelation = new Relation($this->configuration, $stickyCategory); foreach ($faqIds as $faqId) { @@ -715,6 +730,7 @@ public function delete(Request $request): JsonResponse PermissionType::FAQ_DELETE, array_keys($deleteCategoryRelation->getCategories($faqId, $faqLanguage)), ); + $this->userHasPermissionForLanguage(PermissionType::FAQ_DELETE, $faqLanguage); $this->adminLog->log($this->currentUser, AdminLogType::FAQ_DELETE->value . ':' . $faqId); diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/GroupController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/GroupController.php index 6be034ada2..c312f37ab6 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/GroupController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/GroupController.php @@ -25,6 +25,8 @@ use phpMyFAQ\Enums\AdminLogType; use phpMyFAQ\Enums\PermissionType; use phpMyFAQ\Filter; +use phpMyFAQ\Helper\LanguageHelper; +use phpMyFAQ\Language; use phpMyFAQ\Permission\MediumPermission; use phpMyFAQ\Session\Token; use phpMyFAQ\Translation; @@ -229,6 +231,103 @@ public function saveCategoryRestrictions(Request $request): JsonResponse return $this->json(['success' => true], Response::HTTP_OK); } + /** + * @throws Exception + */ + #[Route( + path: 'group/language-restrictions/{groupId}', + name: 'admin.api.group.language-restrictions', + methods: ['GET'], + )] + public function listLanguageRestrictions(Request $request): JsonResponse + { + $this->userHasGroupPermission(); + + $currentUser = CurrentUser::getCurrentUser($this->configuration); + + $groupId = (int) $request->attributes->get('groupId'); + + if (!$currentUser->perm instanceof MediumPermission) { + return $this->json(new \stdClass(), Response::HTTP_OK); + } + + $restrictions = $currentUser->perm->getAllLanguageRestrictions($groupId); + + return $this->json($restrictions === [] ? new \stdClass() : $restrictions, Response::HTTP_OK); + } + + /** + * @throws Exception + */ + #[Route(path: 'group/language-restrictions', name: 'admin.api.group.language-restrictions.save', methods: ['POST'])] + public function saveLanguageRestrictions(Request $request): JsonResponse + { + $this->userHasGroupPermission(); + + $currentUser = CurrentUser::getCurrentUser($this->configuration); + + if (!$currentUser->perm instanceof MediumPermission) { + return $this->json(['error' => 'Group permissions are not enabled.'], Response::HTTP_BAD_REQUEST); + } + + $data = json_decode($request->getContent(), associative: true); + if (!is_array($data)) { + return $this->json(['error' => 'Invalid JSON payload.'], Response::HTTP_BAD_REQUEST); + } + + if (!Token::getInstance($this->session)->verifyToken( + 'save-language-restrictions', + (string) ($data['csrfToken'] ?? ''), + )) { + return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN); + } + + $groupId = (int) ($data['groupId'] ?? 0); + $rightId = (int) ($data['rightId'] ?? 0); + + if ($groupId <= 0 || $rightId <= 0) { + return $this->json(['error' => 'Invalid group or right ID.'], Response::HTTP_BAD_REQUEST); + } + + $rawLanguages = $data['languages'] ?? []; + if (!is_array($rawLanguages)) { + return $this->json(['error' => 'languages must be an array.'], Response::HTTP_BAD_REQUEST); + } + + $languages = array_values(array_filter( + array_map('strval', $rawLanguages), + Language::isASupportedLanguage(...), + )); + + $success = $currentUser->perm->setLanguageRestrictions($groupId, $rightId, $languages); + + if (!$success) { + return $this->json([ + 'error' => 'Failed to save language restrictions.', + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->json(['success' => true], Response::HTTP_OK); + } + + /** + * @throws Exception + */ + #[Route(path: 'group/languages', name: 'admin.api.group.languages', methods: ['GET'])] + public function listLanguages(): JsonResponse + { + $this->userHasGroupPermission(); + + $availableLanguages = LanguageHelper::getAvailableLanguages(); + $languages = array_map( + static fn(string $code, string $label): array => ['code' => $code, 'label' => $label], + array_keys($availableLanguages), + $availableLanguages, + ); + + return $this->json($languages, Response::HTTP_OK); + } + /** * @throws Exception */ diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/UserController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/UserController.php index c2577a0f90..b74f2543bc 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/UserController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/UserController.php @@ -27,7 +27,9 @@ use phpMyFAQ\Enums\PermissionType; use phpMyFAQ\Filter; use phpMyFAQ\Helper\MailHelper; +use phpMyFAQ\Language; use phpMyFAQ\Permission; +use phpMyFAQ\Permission\BasicPermission; use phpMyFAQ\Permission\MediumPermission; use phpMyFAQ\Session\Token; use phpMyFAQ\Strings; @@ -42,6 +44,7 @@ use Symfony\Component\Routing\Attribute\Route; /* @mago-expect lint:cyclomatic-complexity - each endpoint validates its full payload inline; split planned with the admin API rework */ +/* @mago-expect lint:kan-defect - permission-level guards on every endpoint raise the score; split planned with the admin API rework */ final class UserController extends AbstractAdministrationApiController { public function __construct( @@ -629,4 +632,101 @@ public function updateUserRights(Request $request): JsonResponse return $this->json(['success' => $success], Response::HTTP_OK); } + + /** + * @throws Exception + */ + #[Route( + path: 'user/language-restrictions/{userId}', + name: 'admin.api.user.language-restrictions', + methods: ['GET'], + )] + public function listUserLanguageRestrictions(Request $request): JsonResponse + { + $this->userHasPermission(PermissionType::USER_EDIT); + + $userId = (int) Filter::filterVar($request->attributes->get('userId'), FILTER_VALIDATE_INT); + + $currentUser = CurrentUser::getCurrentUser($this->configuration); + $currentUser->getUserById($userId, allowBlockedUsers: true); + + if (!$currentUser->perm instanceof BasicPermission) { + return $this->json(new stdClass(), Response::HTTP_OK); + } + + $restrictions = $currentUser->perm->getAllUserLanguageRestrictions($userId); + + return $this->json($restrictions === [] ? new stdClass() : $restrictions, Response::HTTP_OK); + } + + /** + * @throws Exception + * @throws \Exception + */ + #[Route(path: 'user/language-restrictions', name: 'admin.api.user.language-restrictions.save', methods: ['PUT'])] + public function saveUserLanguageRestrictions(Request $request): JsonResponse + { + $this->userHasPermission(PermissionType::USER_EDIT); + + $data = $this->getJsonObject($request); + + if (!Token::getInstance($this->session)->verifyToken( + page: 'update-user-language-restrictions', + requestToken: (string) ($data->csrfToken ?? ''), + )) { + return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED); + } + + $userId = (int) Filter::filterVar($data->userId ?? null, FILTER_VALIDATE_INT, default: 0); + $rightId = (int) Filter::filterVar($data->rightId ?? null, FILTER_VALIDATE_INT, default: 0); + + if (0 === $userId || 0 === $rightId) { + return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST); + } + + $rawLanguages = is_array($data->languages ?? null) ? $data->languages : []; + $languages = array_values(array_filter( + array_map(static fn(mixed $language): string => (string) $language, $rawLanguages), + Language::isASupportedLanguage(...), + )); + + $actingIsSuperAdmin = $this->currentUser->isSuperAdmin(); + + // A non-SuperAdmin may only restrict a user's language grant to a subset of the + // languages they themselves are allowed for this right. This prevents an administrator + // with the delegable USER_EDIT right from granting language access they do not possess + // (privilege escalation). + if (!$actingIsSuperAdmin) { + $actingUserId = $this->currentUser->getUserId(); + $allowedLanguages = $this->currentUser->perm->getAllowedLanguagesForRight($actingUserId, $rightId); + if ($allowedLanguages !== null) { + foreach ($languages as $language) { + if (!in_array($language, $allowedLanguages, strict: true)) { + return $this->json([ + 'error' => Translation::get(key: 'msgNoPermission'), + ], Response::HTTP_FORBIDDEN); + } + } + } + } + + $user = new User($this->configuration); + $user->getUserById($userId); + + // Defense in depth: a non-SuperAdmin must never be able to alter a SuperAdmin or + // protected account. + if (!$actingIsSuperAdmin && ($user->isSuperAdmin() || $user->getStatus() === 'protected')) { + return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN); + } + + if (!$user->perm instanceof BasicPermission) { + return $this->json(['error' => 'Language restrictions are not enabled.'], Response::HTTP_BAD_REQUEST); + } + + if (!$user->perm->setUserLanguageRestrictions($userId, $rightId, $languages)) { + return $this->json(['error' => Translation::get(key: 'ad_msg_mysqlerr')], Response::HTTP_BAD_REQUEST); + } + + return $this->json(['success' => true], Response::HTTP_OK); + } } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/FaqController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/FaqController.php index a81f0c63ac..fa95c35f90 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/FaqController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/FaqController.php @@ -39,6 +39,7 @@ use phpMyFAQ\Helper\CategoryHelper; use phpMyFAQ\Helper\LanguageHelper; use phpMyFAQ\Helper\UserHelper; +use phpMyFAQ\Language\LanguageRestrictionFilter; use phpMyFAQ\Link; use phpMyFAQ\Link\Util\TitleSlugifier; use phpMyFAQ\Permission\MediumPermission; @@ -156,7 +157,7 @@ public function add(Request $request): Response 'notifyEmail' => '', 'categoryTree' => $this->getFilteredCategoryTree($category, PermissionType::FAQ_ADD), 'selectedCategories' => $categories, - 'languageOptions' => LanguageHelper::renderSelectLanguage($faqData['lang'], false, [], 'lang'), + 'languageOptions' => $this->getFilteredLanguageOptions($faqData['lang'], PermissionType::FAQ_ADD), 'attachments' => [], 'allGroups' => true, 'restrictedGroups' => false, @@ -228,7 +229,7 @@ public function addInCategory(Request $request): Response 'notifyEmail' => '', 'categoryTree' => $this->getFilteredCategoryTree($category, PermissionType::FAQ_ADD), 'selectedCategories' => $categoryId, - 'languageOptions' => LanguageHelper::renderSelectLanguage($faqData['lang'], false, [], 'lang'), + 'languageOptions' => $this->getFilteredLanguageOptions($faqData['lang'], PermissionType::FAQ_ADD), 'attachments' => [], 'allGroups' => true, 'restrictedGroups' => false, @@ -282,6 +283,7 @@ public function edit(Request $request): Response $categories = $categoryRelation->getCategories($faqId, $faqLanguage); $this->userHasPermissionForCategories(PermissionType::FAQ_EDIT, array_keys($categories)); + $this->userHasPermissionForLanguage(PermissionType::FAQ_EDIT, $faqLanguage); $this->adminLog->log($this->currentUser, AdminLogType::FAQ_EDIT->value . ':' . $faqId); @@ -367,7 +369,7 @@ public function edit(Request $request): Response 'notifyEmail' => '', 'categoryTree' => $this->getFilteredCategoryTree($category, PermissionType::FAQ_EDIT), 'selectedCategories' => $categories, - 'languageOptions' => LanguageHelper::renderSelectLanguage($faqLanguage, false, [], 'lang'), + 'languageOptions' => $this->getFilteredLanguageOptions($faqLanguage, PermissionType::FAQ_EDIT), 'attachments' => $attachmentList, 'allGroups' => $allGroups, 'restrictedGroups' => $restrictedGroups, @@ -439,7 +441,7 @@ public function copy(Request $request): Response 'notifyEmail' => '', 'categoryTree' => $this->getFilteredCategoryTree($category, PermissionType::FAQ_ADD), 'selectedCategories' => $categories, - 'languageOptions' => LanguageHelper::renderSelectLanguage($faqLanguage, false, [], 'lang'), + 'languageOptions' => $this->getFilteredLanguageOptions($faqLanguage, PermissionType::FAQ_ADD), 'attachments' => [], 'allGroups' => true, 'restrictedGroups' => false, @@ -511,7 +513,7 @@ public function translate(Request $request): Response 'notifyEmail' => '', 'categoryTree' => $this->getFilteredCategoryTree($category, PermissionType::FAQ_ADD), 'selectedCategories' => $categories, - 'languageOptions' => LanguageHelper::renderSelectLanguage($faqLanguage, false, [], 'lang'), + 'languageOptions' => $this->getFilteredLanguageOptions($faqLanguage, PermissionType::FAQ_ADD), 'attachments' => [], 'allGroups' => true, 'restrictedGroups' => false, @@ -594,7 +596,7 @@ public function answer(Request $request): Response 'notifyEmail' => $questionData['email'] ?? $this->currentUser->getUserData('email'), 'categoryTree' => $this->getFilteredCategoryTree($category, PermissionType::FAQ_ADD), 'selectedCategories' => $categories, - 'languageOptions' => LanguageHelper::renderSelectLanguage($faqLanguage, false, [], 'lang'), + 'languageOptions' => $this->getFilteredLanguageOptions($faqLanguage, PermissionType::FAQ_ADD), 'attachments' => [], 'allGroups' => true, 'restrictedGroups' => false, @@ -692,4 +694,24 @@ private function getFilteredCategoryTree(Category $category, PermissionType $per $permissionType->value, )); } + + /** + * Renders the language , i.e. + * the complement of the allowed set. Null allowed set means unrestricted, + * so nothing is excluded. + * + * @param array $availableLanguages Language code => label map + * @param array|null $allowedLanguageCodes Null = unrestricted + * @return array + */ + public static function excludedLanguages(array $availableLanguages, ?array $allowedLanguageCodes): array + { + if ($allowedLanguageCodes === null) { + return []; + } + + return array_values(array_diff(array_keys($availableLanguages), $allowedLanguageCodes)); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Permission/BasicPermission.php b/phpmyfaq/src/phpMyFAQ/Permission/BasicPermission.php index 11a0a0b4a2..3c75ae9ba1 100644 --- a/phpmyfaq/src/phpMyFAQ/Permission/BasicPermission.php +++ b/phpmyfaq/src/phpMyFAQ/Permission/BasicPermission.php @@ -33,10 +33,13 @@ class BasicPermission implements PermissionInterface { protected BasicPermissionRepository $repository; + protected LanguagePermissionRepository $languageRepository; + public function __construct( protected Configuration $configuration, ) { $this->repository = new BasicPermissionRepository($configuration); + $this->languageRepository = new LanguagePermissionRepository($configuration); } /** @@ -275,6 +278,8 @@ public function getAllRightsData(string $order = 'ASC'): array */ public function refuseAllUserRights(int $userId): bool { + $this->languageRepository->deleteAllForUser($userId); + return $this->repository->refuseAllUserRights($userId); } @@ -306,4 +311,83 @@ public function getAllowedCategoriesForRight(int $userId, mixed $right): ?array { return null; } + + /** + * Basic mode has no groups, but a user's direct right grant can still be + * restricted to specific language(s). + */ + public function hasPermissionForLanguage(int $userId, mixed $right, string $language): bool + { + if (!$this->hasPermission($userId, $right)) { + return false; + } + + $rightId = $this->resolveRightId($right); + + return $this->languageRepository->checkUserRightForLanguage($userId, $rightId, $language); + } + + /** + * Returns the language codes the user's direct right grant is restricted + * to, null if unrestricted, or an empty array if the user does not own + * the right at all. + */ + public function getAllowedLanguagesForRight(int $userId, mixed $right): ?array + { + if (!$this->hasPermission($userId, $right)) { + return []; + } + + $rightId = $this->resolveRightId($right); + $restrictions = $this->languageRepository->getUserLanguageRestrictions($userId, $rightId); + + return $restrictions === [] ? null : $restrictions; + } + + /** + * Returns the language codes that a user's direct right is restricted to. + * An empty array means the right is unrestricted (applies globally). + * + * @return array + */ + public function getUserLanguageRestrictions(int $userId, int $rightId): array + { + return $this->languageRepository->getUserLanguageRestrictions($userId, $rightId); + } + + /** + * Returns all language restrictions for a user, keyed by right ID. + * + * @return array> + */ + public function getAllUserLanguageRestrictions(int $userId): array + { + return $this->languageRepository->getAllUserLanguageRestrictions($userId); + } + + /** + * Sets language restrictions for a user's direct right. + * + * @param array $languages Language codes to restrict to (empty = unrestricted) + */ + public function setUserLanguageRestrictions(int $userId, int $rightId, array $languages): bool + { + return $this->languageRepository->setUserLanguageRestrictions($userId, $rightId, $languages); + } + + /** + * Resolves a right given as ID, name, or PermissionType to its right ID. + */ + private function resolveRightId(mixed $right): int + { + if (!is_numeric($right) && is_string($right)) { + $right = $this->getRightId($right); + } + + if ($right instanceof PermissionType) { + $right = $this->getRightId($right->value); + } + + return (int) $right; + } } diff --git a/phpmyfaq/src/phpMyFAQ/Permission/LanguagePermissionRepository.php b/phpmyfaq/src/phpMyFAQ/Permission/LanguagePermissionRepository.php new file mode 100644 index 0000000000..f62dfb4fb3 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Permission/LanguagePermissionRepository.php @@ -0,0 +1,429 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-08-10 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Permission; + +use phpMyFAQ\Configuration; +use phpMyFAQ\Database; +use phpMyFAQ\Language; + +readonly class LanguagePermissionRepository +{ + public function __construct( + private Configuration $configuration, + ) { + } + + /** + * Returns the language codes that a user's direct right is restricted to. + * An empty array means the right is unrestricted (applies to all languages). + * + * @return array + */ + public function getUserLanguageRestrictions(int $userId, int $rightId): array + { + if ($userId <= 0 || $rightId <= 0) { + return []; + } + + $select = sprintf( + 'SELECT language FROM %sfaquser_right_language WHERE user_id = %d AND right_id = %d', + Database::getTablePrefix(), + $userId, + $rightId, + ); + + return $this->fetchLanguageColumn($select); + } + + /** + * Returns all language restrictions for a user, keyed by right ID. + * + * @return array> + */ + public function getAllUserLanguageRestrictions(int $userId): array + { + if ($userId <= 0) { + return []; + } + + $select = sprintf( + 'SELECT right_id, language FROM %sfaquser_right_language WHERE user_id = %d ORDER BY right_id', + Database::getTablePrefix(), + $userId, + ); + + $res = $this->configuration->getDb()->query($select); + if (!$res) { + return []; + } + + $result = []; + while (true) { + $row = $this->configuration->getDb()->fetchArray($res); + if ($row === false || $row === null || $row === []) { + break; + } + + $rightId = (int) $row['right_id']; + $result[$rightId][] = (string) $row['language']; + } + + return $result; + } + + /** + * Sets the language restrictions for a user's direct right. + * Replaces any existing restrictions for this user-right pair. + * + * @param array $languages Language codes to restrict to (empty = unrestricted) + */ + public function setUserLanguageRestrictions(int $userId, int $rightId, array $languages): bool + { + if ($userId <= 0 || $rightId <= 0) { + return false; + } + + return $this->replaceLanguageRows( + 'faquser_right_language', + 'user_id, right_id, language', + sprintf('user_id = %d AND right_id = %d', $userId, $rightId), + static fn(string $language): string => sprintf('(%d, %d, %s)', $userId, $rightId, $language), + $languages, + ); + } + + /** + * Deletes all language restrictions for a specific user-right pair. + */ + public function deleteUserLanguageRestrictions(int $userId, int $rightId): bool + { + if ($userId <= 0 || $rightId <= 0) { + return false; + } + + $delete = sprintf( + 'DELETE FROM %sfaquser_right_language WHERE user_id = %d AND right_id = %d', + Database::getTablePrefix(), + $userId, + $rightId, + ); + + return (bool) $this->configuration->getDb()->query($delete); + } + + /** + * Deletes all language restrictions for a user. + */ + public function deleteAllForUser(int $userId): bool + { + if ($userId <= 0) { + return false; + } + + $delete = sprintf( + 'DELETE FROM %sfaquser_right_language WHERE user_id = %d', + Database::getTablePrefix(), + $userId, + ); + + return (bool) $this->configuration->getDb()->query($delete); + } + + /** + * Returns true if the user's own direct right grant permits the given language: + * either the grant is unrestricted, or the language is explicitly listed. + */ + public function checkUserRightForLanguage(int $userId, int $rightId, string $language): bool + { + if ($userId <= 0 || $rightId <= 0 || !Language::isASupportedLanguage($language)) { + return false; + } + + $escapedLanguage = $this->configuration->getDb()->escape($language); + + $select = sprintf( + " + SELECT 1 FROM %sfaquser_right fur + WHERE fur.user_id = %d AND fur.right_id = %d + AND ( + NOT EXISTS ( + SELECT 1 FROM %sfaquser_right_language furl + WHERE furl.user_id = fur.user_id AND furl.right_id = fur.right_id + ) + OR EXISTS ( + SELECT 1 FROM %sfaquser_right_language furl + WHERE furl.user_id = fur.user_id + AND furl.right_id = fur.right_id + AND furl.language = '%s' + ) + )", + Database::getTablePrefix(), + $userId, + $rightId, + Database::getTablePrefix(), + Database::getTablePrefix(), + $escapedLanguage, + ); + + $res = $this->configuration->getDb()->query($select); + + return $res !== false && $this->configuration->getDb()->numRows($res) > 0; + } + + /** + * Returns the language codes that a group's right is restricted to. + * An empty array means the right is unrestricted (applies to all languages). + * + * @return array + */ + public function getLanguageRestrictions(int $groupId, int $rightId): array + { + if ($groupId <= 0 || $rightId <= 0) { + return []; + } + + $select = sprintf( + 'SELECT language FROM %sfaqgroup_right_language WHERE group_id = %d AND right_id = %d', + Database::getTablePrefix(), + $groupId, + $rightId, + ); + + return $this->fetchLanguageColumn($select); + } + + /** + * Returns all language restrictions for a group, keyed by right ID. + * + * @return array> + */ + public function getAllLanguageRestrictions(int $groupId): array + { + if ($groupId <= 0) { + return []; + } + + $select = sprintf( + 'SELECT right_id, language FROM %sfaqgroup_right_language WHERE group_id = %d ORDER BY right_id', + Database::getTablePrefix(), + $groupId, + ); + + $res = $this->configuration->getDb()->query($select); + if (!$res) { + return []; + } + + $result = []; + while (true) { + $row = $this->configuration->getDb()->fetchArray($res); + if ($row === false || $row === null || $row === []) { + break; + } + + $rightId = (int) $row['right_id']; + $result[$rightId][] = (string) $row['language']; + } + + return $result; + } + + /** + * Sets the language restrictions for a group's right. + * Replaces any existing restrictions for this group-right pair. + * + * @param array $languages Language codes to restrict to (empty = unrestricted) + */ + public function setLanguageRestrictions(int $groupId, int $rightId, array $languages): bool + { + if ($groupId <= 0 || $rightId <= 0) { + return false; + } + + return $this->replaceLanguageRows( + 'faqgroup_right_language', + 'group_id, right_id, language', + sprintf('group_id = %d AND right_id = %d', $groupId, $rightId), + static fn(string $language): string => sprintf('(%d, %d, %s)', $groupId, $rightId, $language), + $languages, + ); + } + + /** + * Deletes all language restrictions for a specific group-right pair. + */ + public function deleteLanguageRestrictions(int $groupId, int $rightId): bool + { + if ($groupId <= 0 || $rightId <= 0) { + return false; + } + + $delete = sprintf( + 'DELETE FROM %sfaqgroup_right_language WHERE group_id = %d AND right_id = %d', + Database::getTablePrefix(), + $groupId, + $rightId, + ); + + return (bool) $this->configuration->getDb()->query($delete); + } + + /** + * Deletes all language restrictions for a group. + */ + public function deleteAllForGroup(int $groupId): bool + { + if ($groupId <= 0) { + return false; + } + + $delete = sprintf( + 'DELETE FROM %sfaqgroup_right_language WHERE group_id = %d', + Database::getTablePrefix(), + $groupId, + ); + + return (bool) $this->configuration->getDb()->query($delete); + } + + /** + * Checks if a user has a specific right for a given language via group membership. + * Returns true if the user belongs to a group that either: + * - Has no language restrictions for this right (global), OR + * - Has the specific language in its restrictions. + */ + public function checkUserGroupRightForLanguage(int $userId, int $rightId, string $language): bool + { + if ($userId <= 0 || $rightId <= 0 || !Language::isASupportedLanguage($language)) { + return false; + } + + $escapedLanguage = $this->configuration->getDb()->escape($language); + + $select = sprintf( + " + SELECT + fgr.group_id + FROM + %sfaqgroup_right fgr + INNER JOIN + %sfaquser_group fug ON fgr.group_id = fug.group_id + WHERE + fug.user_id = %d AND + fgr.right_id = %d AND + ( + NOT EXISTS ( + SELECT 1 FROM %sfaqgroup_right_language fgrl + WHERE fgrl.group_id = fgr.group_id AND fgrl.right_id = fgr.right_id + ) + OR EXISTS ( + SELECT 1 FROM %sfaqgroup_right_language fgrl + WHERE fgrl.group_id = fgr.group_id + AND fgrl.right_id = fgr.right_id + AND fgrl.language = '%s' + ) + )", + Database::getTablePrefix(), + Database::getTablePrefix(), + $userId, + $rightId, + Database::getTablePrefix(), + Database::getTablePrefix(), + $escapedLanguage, + ); + + $res = $this->configuration->getDb()->query($select); + + return $res !== false && $this->configuration->getDb()->numRows($res) > 0; + } + + /** + * @return array + */ + private function fetchLanguageColumn(string $select): array + { + $res = $this->configuration->getDb()->query($select); + if (!$res) { + return []; + } + + $result = []; + while (true) { + $row = $this->configuration->getDb()->fetchArray($res); + if ($row === false || $row === null || $row === []) { + break; + } + + $result[] = (string) $row['language']; + } + + return $result; + } + + /** + * Deletes all rows matching $whereClause on $table, then re-inserts one row per + * supported language code, built via $rowBuilder. Runs inside a transaction so + * the replace is atomic. Unsupported language codes are silently skipped. + * + * @param array $languages + */ + private function replaceLanguageRows( + string $table, + string $columns, + string $whereClause, + callable $rowBuilder, + array $languages, + ): bool { + $db = $this->configuration->getDb(); + + $db->query('BEGIN'); + + $delete = sprintf('DELETE FROM %s%s WHERE %s', Database::getTablePrefix(), $table, $whereClause); + if (!$db->query($delete)) { + $db->query('ROLLBACK'); + return false; + } + + foreach ($languages as $language) { + if (!Language::isASupportedLanguage($language)) { + continue; + } + + $escapedLanguage = sprintf("'%s'", $db->escape($language)); + + $insert = sprintf( + 'INSERT INTO %s%s (%s) VALUES %s', + Database::getTablePrefix(), + $table, + $columns, + $rowBuilder($escapedLanguage), + ); + + if (!$db->query($insert)) { + $db->query('ROLLBACK'); + return false; + } + } + + $db->query('COMMIT'); + + return true; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Permission/MediumPermission.php b/phpmyfaq/src/phpMyFAQ/Permission/MediumPermission.php index 9fa0d66e34..8c7e1142f5 100644 --- a/phpmyfaq/src/phpMyFAQ/Permission/MediumPermission.php +++ b/phpmyfaq/src/phpMyFAQ/Permission/MediumPermission.php @@ -213,6 +213,7 @@ public function deleteGroup(int $groupId): bool } $this->categoryPermissionRepository->deleteAllForGroup($groupId); + $this->languageRepository->deleteAllForGroup($groupId); return $this->mediumRepository->deleteGroupRights($groupId); } @@ -600,6 +601,133 @@ public function getAllowedCategoriesForRight(int $userId, mixed $right): ?array return array_values(array_unique($allowedCategories)); } + /** + * Returns true if the user has the specified right for the given language, + * taking into account both the user's own direct language restriction and + * group-level language restrictions. Unlike category restrictions, a + * direct user-right grant CAN be language-restricted. + * + * The right is usable for a language if either the user's own grant is + * unrestricted or matches the language, OR any qualifying group's grant + * is unrestricted or matches the language (union of all grants). + * + * @param int $userId User ID + * @param mixed $right Right ID, name, or PermissionType enum + * @param string $language Language code (e.g. 'en', 'de') + * @param CurrentUser|null $currentUser Optional pre-loaded user to avoid repeated instantiation + * @throws Exception + */ + public function hasPermissionForLanguage( + int $userId, + mixed $right, + string $language, + ?CurrentUser $currentUser = null, + ): bool { + if ($currentUser === null) { + $currentUser = new CurrentUser($this->configuration); + $currentUser->getUserById($userId); + } + + if ($currentUser->isSuperAdmin()) { + return true; + } + + $rightId = $this->resolveRightId($right); + + if ( + $this->checkUserRight($userId, $rightId) + && $this->languageRepository->checkUserRightForLanguage($userId, $rightId, $language) + ) { + return true; + } + + return $this->languageRepository->checkUserGroupRightForLanguage($userId, $rightId, $language); + } + + /** + * Returns the language codes that a group's right is restricted to. + * An empty array means the right is unrestricted (applies globally). + * + * @return array + */ + public function getLanguageRestrictions(int $groupId, int $rightId): array + { + return $this->languageRepository->getLanguageRestrictions($groupId, $rightId); + } + + /** + * Returns all language restrictions for a group, keyed by right ID. + * + * @return array> + */ + public function getAllLanguageRestrictions(int $groupId): array + { + return $this->languageRepository->getAllLanguageRestrictions($groupId); + } + + /** + * Sets language restrictions for a group's right. + * + * @param array $languages Language codes to restrict to (empty = unrestricted) + */ + public function setLanguageRestrictions(int $groupId, int $rightId, array $languages): bool + { + return $this->languageRepository->setLanguageRestrictions($groupId, $rightId, $languages); + } + + /** + * Returns the language codes in which the user may exercise the right: + * the union of the user's own direct grant (if any) and every group + * grant that also holds the right. Null means unrestricted (superadmin, + * or any single grant is itself unrestricted). An empty array means the + * user does not hold the right through any grant at all. + * + * @param int $userId User ID + * @param mixed $right Right ID, right name, or PermissionType value + * @return array|null + * @throws Exception + */ + #[\Override] + public function getAllowedLanguagesForRight(int $userId, mixed $right): ?array + { + $currentUser = new CurrentUser($this->configuration); + $currentUser->getUserById($userId); + + if ($currentUser->isSuperAdmin()) { + return null; + } + + $rightId = $this->resolveRightId($right); + $allowedLanguages = []; + $hasAnyGrant = false; + + if ($this->checkUserRight($userId, $rightId)) { + $hasAnyGrant = true; + $userRestrictions = $this->languageRepository->getUserLanguageRestrictions($userId, $rightId); + if ($userRestrictions === []) { + return null; + } + + $allowedLanguages = [...$allowedLanguages, ...$userRestrictions]; + } + + foreach ($this->getUserGroups($userId) as $groupId) { + if (!in_array($rightId, $this->getGroupRights($groupId), strict: true)) { + continue; + } + + $hasAnyGrant = true; + $groupRestrictions = $this->languageRepository->getLanguageRestrictions($groupId, $rightId); + if ($groupRestrictions === []) { + return null; + } + + $allowedLanguages = [...$allowedLanguages, ...$groupRestrictions]; + } + + return $hasAnyGrant ? array_values(array_unique($allowedLanguages)) : []; + } + /** * Resolves a right given as ID, name, or PermissionType to its right ID. */ diff --git a/phpmyfaq/src/phpMyFAQ/Permission/PermissionInterface.php b/phpmyfaq/src/phpMyFAQ/Permission/PermissionInterface.php index 51b95a052e..3be3316b60 100644 --- a/phpmyfaq/src/phpMyFAQ/Permission/PermissionInterface.php +++ b/phpmyfaq/src/phpMyFAQ/Permission/PermissionInterface.php @@ -145,4 +145,28 @@ public function hasPermissionForCategory(int $userId, mixed $right, int $categor * @return array|null */ public function getAllowedCategoriesForRight(int $userId, mixed $right): ?array; + + /** + * Returns true if the user owns the right for the given language. + * Unlike category restrictions, language restrictions apply to both + * direct user-rights and group-rights: Basic mode has no groups but can + * still restrict a user's right to specific language(s) via a direct + * grant. + * + * @param int $userId User ID + * @param mixed $right Right ID, right name, or PermissionType value + * @param string $language Language code (e.g. 'en', 'de') + */ + public function hasPermissionForLanguage(int $userId, mixed $right, string $language): bool; + + /** + * Returns the language codes in which the user may exercise the right, + * null if the right is unrestricted (applies to all languages), or an + * empty array if the user cannot exercise the right in any language. + * + * @param int $userId User ID + * @param mixed $right Right ID, right name, or PermissionType value + * @return array|null + */ + public function getAllowedLanguagesForRight(int $userId, mixed $right): ?array; } diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php index d48fce1230..c2b4beb040 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php @@ -66,6 +66,7 @@ public function getAllTables(): array 'faqgroup' => $this->faqgroup(), 'faqgroup_right' => $this->faqgroupRight(), 'faqgroup_right_category' => $this->faqgroupRightCategory(), + 'faqgroup_right_language' => $this->faqgroupRightLanguage(), 'faqapi_keys' => $this->faqapiKeys(), 'faqoauth_clients' => $this->faqoauthClients(), 'faqoauth_scopes' => $this->faqoauthScopes(), @@ -90,6 +91,7 @@ public function getAllTables(): array 'faquserlogin' => $this->faquserlogin(), 'faquser_group' => $this->faquserGroup(), 'faquser_right' => $this->faquserRight(), + 'faquser_right_language' => $this->faquserRightLanguage(), 'faqvisits' => $this->faqvisits(), 'faqvoting' => $this->faqvoting(), 'faqchat_messages' => $this->faqchatMessages(), @@ -738,6 +740,26 @@ public function faquserRight(): TableBuilder ->primaryKey(['user_id', 'right_id']); } + public function faquserRightLanguage(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faquser_right_language') + ->integer('user_id', false) + ->integer('right_id', false) + ->varchar('language', 5, false) + ->primaryKey(['user_id', 'right_id', 'language']); + } + + public function faqgroupRightLanguage(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqgroup_right_language') + ->integer('group_id', false) + ->integer('right_id', false) + ->varchar('language', 5, false) + ->primaryKey(['group_id', 'right_id', 'language']); + } + public function faqvisits(): TableBuilder { return new TableBuilder($this->dialect) diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/MigrationRegistry.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/MigrationRegistry.php index a031434e47..5c4692aaae 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/MigrationRegistry.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/MigrationRegistry.php @@ -69,6 +69,7 @@ private function registerDefaultMigrations(): void '4.1.0-alpha.2' => Versions\Migration410Alpha2::class, '4.1.0-alpha.3' => Versions\Migration410Alpha3::class, '4.2.0-alpha' => Versions\Migration420Alpha::class, + '4.2.0-alpha.2' => Versions\Migration420Alpha2::class, ]; } diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha2.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha2.php new file mode 100644 index 0000000000..0dc44b3111 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha2.php @@ -0,0 +1,158 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-08-10 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Setup\Migration\Versions; + +use phpMyFAQ\Setup\Migration\AbstractMigration; +use phpMyFAQ\Setup\Migration\Operations\OperationRecorder; + +readonly class Migration420Alpha2 extends AbstractMigration +{ + public function getVersion(): string + { + return '4.2.0-alpha.2'; + } + + public function getDependencies(): array + { + return ['4.2.0-alpha']; + } + + public function getDescription(): string + { + return 'Add faquser_right_language and faqgroup_right_language tables for granular language-based permissions'; + } + + public function up(OperationRecorder $recorder): void + { + $intType = $this->integerType(); + + if ($this->isMySql()) { + $recorder->addSql( + sprintf( + 'CREATE TABLE IF NOT EXISTS %sfaquser_right_language ( + user_id %s NOT NULL, + right_id %s NOT NULL, + language VARCHAR(5) NOT NULL, + PRIMARY KEY (user_id, right_id, language) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci', + $this->tablePrefix, + $intType, + $intType, + ), + 'Create faquser_right_language table (MySQL)', + ); + + $recorder->addSql( + sprintf( + 'CREATE TABLE IF NOT EXISTS %sfaqgroup_right_language ( + group_id %s NOT NULL, + right_id %s NOT NULL, + language VARCHAR(5) NOT NULL, + PRIMARY KEY (group_id, right_id, language) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci', + $this->tablePrefix, + $intType, + $intType, + ), + 'Create faqgroup_right_language table (MySQL)', + ); + } + + if ($this->isPostgreSql()) { + $recorder->addSql( + sprintf('CREATE TABLE IF NOT EXISTS %sfaquser_right_language ( + user_id %s NOT NULL, + right_id %s NOT NULL, + language VARCHAR(5) NOT NULL, + PRIMARY KEY (user_id, right_id, language) + )', $this->tablePrefix, $intType, $intType), + 'Create faquser_right_language table (PostgreSQL)', + ); + + $recorder->addSql( + sprintf('CREATE TABLE IF NOT EXISTS %sfaqgroup_right_language ( + group_id %s NOT NULL, + right_id %s NOT NULL, + language VARCHAR(5) NOT NULL, + PRIMARY KEY (group_id, right_id, language) + )', $this->tablePrefix, $intType, $intType), + 'Create faqgroup_right_language table (PostgreSQL)', + ); + } + + if ($this->isSqlite()) { + $recorder->addSql( + sprintf('CREATE TABLE IF NOT EXISTS %sfaquser_right_language ( + user_id %s NOT NULL, + right_id %s NOT NULL, + language VARCHAR(5) NOT NULL, + PRIMARY KEY (user_id, right_id, language) + )', $this->tablePrefix, $intType, $intType), + 'Create faquser_right_language table (SQLite)', + ); + + $recorder->addSql( + sprintf('CREATE TABLE IF NOT EXISTS %sfaqgroup_right_language ( + group_id %s NOT NULL, + right_id %s NOT NULL, + language VARCHAR(5) NOT NULL, + PRIMARY KEY (group_id, right_id, language) + )', $this->tablePrefix, $intType, $intType), + 'Create faqgroup_right_language table (SQLite)', + ); + } + + if ($this->isSqlServer()) { + $recorder->addSql( + sprintf( + 'IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = \'%sfaquser_right_language\') ' + . 'CREATE TABLE %sfaquser_right_language ( + user_id %s NOT NULL, + right_id %s NOT NULL, + language VARCHAR(5) NOT NULL, + PRIMARY KEY (user_id, right_id, language) + )', + $this->tablePrefix, + $this->tablePrefix, + $intType, + $intType, + ), + 'Create faquser_right_language table (SQL Server)', + ); + + $recorder->addSql( + sprintf( + 'IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = \'%sfaqgroup_right_language\') ' + . 'CREATE TABLE %sfaqgroup_right_language ( + group_id %s NOT NULL, + right_id %s NOT NULL, + language VARCHAR(5) NOT NULL, + PRIMARY KEY (group_id, right_id, language) + )', + $this->tablePrefix, + $this->tablePrefix, + $intType, + $intType, + ), + 'Create faqgroup_right_language table (SQL Server)', + ); + } + } +} diff --git a/phpmyfaq/translations/language_en.php b/phpmyfaq/translations/language_en.php index f882336aa2..c116ce9f03 100644 --- a/phpmyfaq/translations/language_en.php +++ b/phpmyfaq/translations/language_en.php @@ -1853,4 +1853,17 @@ $PMF_LANG['msgAttachmentTooBig'] = 'This file exceeds the maximum attachment size.'; $PMF_LANG['msgAttachmentsUploaded'] = 'Attachments uploaded successfully.'; +// added v4.2.0-alpha.2 - 2026-08-10 by Thorsten +$PMF_LANG['ad_group_language_restrictions'] = 'Language Restrictions'; +$PMF_LANG['ad_group_language_restrictions_help'] = 'Select a group to manage language restrictions for its permissions.'; +$PMF_LANG['ad_group_language_restrictions_select'] = 'Select languages to restrict this permission. Leave empty for unrestricted access.'; +$PMF_LANG['ad_group_language_restrictions_saved'] = 'Language restrictions saved.'; +$PMF_LANG['ad_group_language_restrictions_error'] = 'Failed to save language restrictions.'; +$PMF_LANG['ad_user_language_restrictions'] = 'Language Restrictions'; +$PMF_LANG['ad_user_language_restrictions_help'] = "Select languages to restrict this user's permissions to."; +$PMF_LANG['ad_user_language_restrictions_select'] = 'Select languages to restrict this permission. Leave empty for unrestricted access.'; +$PMF_LANG['ad_user_language_restrictions_saved'] = 'Language restrictions saved.'; +$PMF_LANG['ad_user_language_restrictions_error'] = 'Failed to save language restrictions.'; +$PMF_LANG['ad_user_no_permissions'] = 'No permissions assigned to this user.'; + return $PMF_LANG; diff --git a/tests/phpMyFAQ/Administration/AdminMenuBuilderTest.php b/tests/phpMyFAQ/Administration/AdminMenuBuilderTest.php index a9fda894cf..818fe9601f 100644 --- a/tests/phpMyFAQ/Administration/AdminMenuBuilderTest.php +++ b/tests/phpMyFAQ/Administration/AdminMenuBuilderTest.php @@ -138,6 +138,16 @@ public function getAllowedCategoriesForRight(int $userId, mixed $right): ?array return null; } + public function hasPermissionForLanguage(int $userId, mixed $right, string $language): bool + { + return $this->hasPermission($userId, $right); + } + + public function getAllowedLanguagesForRight(int $userId, mixed $right): ?array + { + return null; + } + public function getAllUserRights(int $userId): array { return array_column($this->rights, 'right_id'); diff --git a/tests/phpMyFAQ/Attachment/AttachmentServiceTest.php b/tests/phpMyFAQ/Attachment/AttachmentServiceTest.php index 2913d0b6c8..483b65d423 100644 --- a/tests/phpMyFAQ/Attachment/AttachmentServiceTest.php +++ b/tests/phpMyFAQ/Attachment/AttachmentServiceTest.php @@ -460,6 +460,16 @@ public function getAllowedCategoriesForRight(int $userId, mixed $right): ?array return null; } + public function hasPermissionForLanguage(int $userId, mixed $right, string $language): bool + { + return $this->hasPermission($userId, $right); + } + + public function getAllowedLanguagesForRight(int $userId, mixed $right): ?array + { + return null; + } + public function getAllUserRights(int $userId): array { return $this->userRights; diff --git a/tests/phpMyFAQ/Controller/Administration/Api/FaqControllerTest.php b/tests/phpMyFAQ/Controller/Administration/Api/FaqControllerTest.php index 2a7d8a888d..c97548cd9f 100644 --- a/tests/phpMyFAQ/Controller/Administration/Api/FaqControllerTest.php +++ b/tests/phpMyFAQ/Controller/Administration/Api/FaqControllerTest.php @@ -228,6 +228,24 @@ private function createAuthenticatedContainerWithAllowedCategories( && $categoryId !== 666, // sentinel forbidden category for tests ); $permission->method('getAllowedCategoriesForRight')->willReturn($allowedCategories); + $permission + ->method('hasPermissionForLanguage') + ->willReturnCallback( + static fn(int $userId, mixed $right, string $language): bool => $userId === 42 + && in_array( + $right, + [ + PermissionType::FAQ_ADD->value, + PermissionType::FAQ_EDIT->value, + PermissionType::FAQ_DELETE->value, + PermissionType::FAQ_APPROVE->value, + PermissionType::FAQ_TRANSLATE->value, + ], + true, + ) + && $language !== 'fr', // sentinel forbidden language for tests + ); + $permission->method('getAllowedLanguagesForRight')->willReturn(null); $currentUser = $this->createMock(CurrentUser::class); $currentUser->perm = $permission; @@ -509,6 +527,47 @@ public function testCreateReturnsForbiddenForRestrictedCategory(): void $controller->create($request); } + /** + * @throws \Exception + */ + public function testCreateReturnsForbiddenForRestrictedLanguage(): void + { + $session = new Session(new MockArraySessionStorage()); + $csrfToken = Token::getInstance($session)->getTokenString('pmf-csrf-token'); + $this->setCsrfCookie('pmf-csrf-token', $csrfToken); + + $request = new Request([], [], [], [], [], [], json_encode([ + 'data' => [ + 'pmf-csrf-token' => $csrfToken, + 'question' => 'Restricted question', + 'categories[]' => [1], + 'lang' => 'fr', + 'tags' => '', + 'active' => 'yes', + 'answer' => 'Restricted answer', + 'keywords' => '', + 'author' => 'Author', + 'email' => 'author@example.com', + 'userpermission' => 'restricted', + 'restricted_users' => [], + 'grouppermission' => 'restricted', + 'restricted_groups' => [], + 'changed' => '', + 'notes' => '', + 'serpTitle' => '', + 'serpDescription' => '', + 'openQuestionId' => 0, + ], + ], JSON_THROW_ON_ERROR)); + + $controller = $this->createController(); + $controller->setContainer($this->createAuthenticatedContainer($session)); + + $this->expectException(ForbiddenException::class); + $this->expectExceptionMessage('User has no "FAQ_ADD" permission for language "fr".'); + $controller->create($request); + } + /** * @throws \Exception */ @@ -1511,6 +1570,52 @@ public function testUpdateReturnsForbiddenWhenTargetCategoryIsRestricted(): void $controller->update($request); } + /** + * @throws \Exception + */ + public function testUpdateReturnsForbiddenWhenTargetLanguageIsRestricted(): void + { + $session = new Session(new MockArraySessionStorage()); + $csrfToken = Token::getInstance($session)->getTokenString('pmf-csrf-token'); + $this->setCsrfCookie('pmf-csrf-token', $csrfToken); + + $request = new Request([], [], [], [], [], [], json_encode([ + 'data' => [ + 'pmf-csrf-token' => $csrfToken, + 'faqId' => 1, + 'solutionId' => 1, + 'revisionId' => 0, + 'question' => 'Updated question?', + 'categories[]' => [1], + 'lang' => 'fr', + 'tags' => '', + 'active' => 'yes', + 'answer' => 'Updated answer', + 'keywords' => '', + 'author' => 'Author', + 'email' => 'author@example.com', + 'userpermission' => 'restricted', + 'restricted_users' => [], + 'grouppermission' => 'restricted', + 'restricted_groups' => [], + 'changed' => 'Updated', + 'date' => '2026-03-08 10:00:00', + 'notes' => '', + 'revision' => 'no', + 'recordDateHandling' => 'keepDate', + 'serpTitle' => '', + 'serpDescription' => '', + ], + ], JSON_THROW_ON_ERROR)); + + $controller = $this->createController(); + $controller->setContainer($this->createAuthenticatedContainer($session)); + + $this->expectException(ForbiddenException::class); + $this->expectExceptionMessage('User has no "FAQ_EDIT" permission for language "fr".'); + $controller->update($request); + } + /** * @throws \Exception */ @@ -1536,6 +1641,31 @@ public function testDeleteReturnsForbiddenWhenFaqIsInRestrictedCategory(): void $controller->delete($request); } + /** + * @throws \Exception + */ + public function testDeleteReturnsForbiddenWhenFaqIsInRestrictedLanguage(): void + { + $this->seedFaqRecord(categoryId: 1, language: 'fr'); + + $session = new Session(new MockArraySessionStorage()); + $csrfToken = Token::getInstance($session)->getTokenString('pmf-csrf-token'); + $this->setCsrfCookie('pmf-csrf-token', $csrfToken); + + $request = new Request([], [], [], [], [], [], json_encode([ + 'csrf' => $csrfToken, + 'faqId' => 1, + 'faqLanguage' => 'fr', + ], JSON_THROW_ON_ERROR)); + + $controller = $this->createController(); + $controller->setContainer($this->createAuthenticatedContainer($session)); + + $this->expectException(ForbiddenException::class); + $this->expectExceptionMessage('User has no "FAQ_DELETE" permission for language "fr".'); + $controller->delete($request); + } + /** * @throws \Exception */ @@ -1562,6 +1692,32 @@ public function testActivateReturnsForbiddenWhenFaqIsInRestrictedCategory(): voi $controller->activate($request); } + /** + * @throws \Exception + */ + public function testActivateReturnsForbiddenWhenLanguageIsRestricted(): void + { + $this->seedFaqRecord(categoryId: 1, language: 'fr'); + + $session = new Session(new MockArraySessionStorage()); + $csrfToken = Token::getInstance($session)->getTokenString('pmf-csrf-token'); + $this->setCsrfCookie('pmf-csrf-token', $csrfToken); + + $request = new Request([], [], [], [], [], [], json_encode([ + 'csrf' => $csrfToken, + 'faqIds' => [1], + 'faqLanguage' => 'fr', + 'checked' => true, + ], JSON_THROW_ON_ERROR)); + + $controller = $this->createController(); + $controller->setContainer($this->createAuthenticatedContainer($session)); + + $this->expectException(ForbiddenException::class); + $this->expectExceptionMessage('User has no "FAQ_APPROVE" permission for language "fr".'); + $controller->activate($request); + } + /** * @throws \Exception */ @@ -1588,6 +1744,32 @@ public function testStickyReturnsForbiddenWhenFaqIsInRestrictedCategory(): void $controller->sticky($request); } + /** + * @throws \Exception + */ + public function testStickyReturnsForbiddenWhenLanguageIsRestricted(): void + { + $this->seedFaqRecord(categoryId: 1, language: 'fr'); + + $session = new Session(new MockArraySessionStorage()); + $csrfToken = Token::getInstance($session)->getTokenString('pmf-csrf-token'); + $this->setCsrfCookie('pmf-csrf-token', $csrfToken); + + $request = new Request([], [], [], [], [], [], json_encode([ + 'csrf' => $csrfToken, + 'faqIds' => [1], + 'faqLanguage' => 'fr', + 'checked' => true, + ], JSON_THROW_ON_ERROR)); + + $controller = $this->createController(); + $controller->setContainer($this->createAuthenticatedContainer($session)); + + $this->expectException(ForbiddenException::class); + $this->expectExceptionMessage('User has no "FAQ_EDIT" permission for language "fr".'); + $controller->sticky($request); + } + /** * @throws \Exception */ @@ -1601,6 +1783,70 @@ public function testListByCategoryReturnsForbiddenForRestrictedCategory(): void $controller->listByCategory(new Request([], [], ['categoryId' => 666, 'language' => 'en'])); } + /** + * @throws \Exception + */ + public function testListByCategoryReturnsForbiddenForRestrictedLanguage(): void + { + $controller = $this->createController(); + $controller->setContainer($this->createAuthenticatedContainer()); + + $this->expectException(ForbiddenException::class); + $this->expectExceptionMessage('User has no "FAQ_EDIT" permission for language "fr".'); + $controller->listByCategory(new Request([], [], ['categoryId' => 1, 'language' => 'fr'])); + } + + /** + * isAllowedToTranslate must combine category and language restrictions + * with AND: a user allowed to translate category 1 but not language 'en' + * must not be reported as allowed to translate. + * + * @throws \Exception + */ + public function testListByCategoryIsAllowedToTranslateCombinesCategoryAndLanguage(): void + { + $permission = $this->createMock(PermissionInterface::class); + $permission->method('hasPermission')->willReturn(true); + $permission->method('hasPermissionForCategory')->willReturn(true); + $permission->method('getAllowedCategoriesForRight')->willReturn(null); + $permission + ->method('hasPermissionForLanguage') + ->willReturnCallback( + static fn(int $userId, mixed $right, string $language): bool => $right + !== PermissionType::FAQ_TRANSLATE->value, + ); + $permission->method('getAllowedLanguagesForRight')->willReturn(null); + + $currentUser = $this->createMock(CurrentUser::class); + $currentUser->perm = $permission; + $currentUser->method('isLoggedIn')->willReturn(true); + $currentUser->method('getUserId')->willReturn(42); + + $session = new Session(new MockArraySessionStorage()); + $adminLog = $this->createStub(AdminLog::class); + $container = $this->createStub(ContainerInterface::class); + $container + ->method('get') + ->willReturnCallback(function (string $id) use ($currentUser, $session, $adminLog) { + return match ($id) { + 'phpmyfaq.configuration' => $this->configuration, + 'phpmyfaq.user.current_user' => $currentUser, + 'session' => $session, + 'phpmyfaq.admin.admin-log' => $adminLog, + default => null, + }; + }); + + $controller = $this->createController(); + $controller->setContainer($container); + + $response = $controller->listByCategory(new Request([], [], ['categoryId' => 1, 'language' => 'en'])); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertFalse($payload['isAllowedToTranslate']); + } + private function seedOrphanedFaqRecord(int $faqId = 1, string $language = 'en'): void { $this->configuration diff --git a/tests/phpMyFAQ/Controller/Administration/Api/GroupControllerTest.php b/tests/phpMyFAQ/Controller/Administration/Api/GroupControllerTest.php index e039d49eed..7587d45adc 100644 --- a/tests/phpMyFAQ/Controller/Administration/Api/GroupControllerTest.php +++ b/tests/phpMyFAQ/Controller/Administration/Api/GroupControllerTest.php @@ -418,6 +418,131 @@ public function testListPermissionsReturnsGroupRightsForAuthenticatedUser(): voi self::assertSame([1, 2], $payload); } + /** + * @throws \Exception + */ + public function testListLanguageRestrictionsRequiresGroupPermission(): void + { + $request = new Request([], [], ['groupId' => self::TEST_GROUP_ID]); + $controller = new GroupController(); + + $this->expectException(\Exception::class); + $controller->listLanguageRestrictions($request); + } + + /** + * @throws \Exception + */ + public function testListLanguageRestrictionsReturnsEmptyWhenNoneSet(): void + { + $this->seedCurrentUserSession(); + $this->seedGroupFixtures(); + + $controller = new GroupController(); + $controller->setContainer($this->createAuthenticatedContainer()); + + $response = $controller->listLanguageRestrictions(new Request([], [], ['groupId' => self::TEST_GROUP_ID])); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame([], $payload); + } + + /** + * @throws \Exception + */ + public function testSaveLanguageRestrictionsRequiresGroupPermission(): void + { + $controller = new GroupController(); + + $this->expectException(\Exception::class); + $controller->saveLanguageRestrictions(new Request(content: '{}')); + } + + /** + * @throws \Exception + */ + public function testSaveLanguageRestrictionsRejectsInvalidCsrfToken(): void + { + $this->seedCurrentUserSession(); + $this->seedGroupFixtures(); + + $controller = new GroupController(); + $controller->setContainer($this->createAuthenticatedContainer()); + + $response = $controller->saveLanguageRestrictions(new Request(content: json_encode([ + 'groupId' => self::TEST_GROUP_ID, + 'rightId' => 1, + 'languages' => ['en'], + 'csrfToken' => 'invalid-token', + ], JSON_THROW_ON_ERROR))); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + self::assertSame('Invalid CSRF token.', $payload['error']); + } + + /** + * @throws \Exception + */ + public function testSaveLanguageRestrictionsSavesAndSkipsUnsupportedLanguages(): void + { + $this->seedCurrentUserSession(); + $this->seedGroupFixtures(); + + $session = new Session(new MockArraySessionStorage()); + $csrfToken = Token::getInstance($session)->getTokenString('save-language-restrictions'); + $this->setCsrfCookie('save-language-restrictions', $csrfToken); + + $controller = new GroupController(); + $controller->setContainer($this->createAuthenticatedContainer($session)); + + $response = $controller->saveLanguageRestrictions(new Request(content: json_encode([ + 'groupId' => self::TEST_GROUP_ID, + 'rightId' => 1, + 'languages' => ['en', 'not-a-language'], + 'csrfToken' => $csrfToken, + ], JSON_THROW_ON_ERROR))); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertTrue($payload['success']); + + $listResponse = $controller->listLanguageRestrictions(new Request([], [], ['groupId' => self::TEST_GROUP_ID])); + $listPayload = json_decode((string) $listResponse->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertSame(['1' => ['en']], $listPayload); + $this->removeCsrfCookie('save-language-restrictions'); + } + + /** + * @throws \Exception + */ + public function testListLanguagesRequiresGroupPermission(): void + { + $controller = new GroupController(); + + $this->expectException(\Exception::class); + $controller->listLanguages(); + } + + /** + * @throws \Exception + */ + public function testListLanguagesReturnsAvailableLanguages(): void + { + $this->seedCurrentUserSession(); + + $controller = new GroupController(); + $controller->setContainer($this->createAuthenticatedContainer()); + + $response = $controller->listLanguages(); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertNotEmpty($payload); + self::assertContains(['code' => 'en', 'label' => 'English'], $payload); + } + /** * @throws \Exception */ diff --git a/tests/phpMyFAQ/Controller/Administration/Api/UserControllerTest.php b/tests/phpMyFAQ/Controller/Administration/Api/UserControllerTest.php index 5c287d929e..6d55505919 100644 --- a/tests/phpMyFAQ/Controller/Administration/Api/UserControllerTest.php +++ b/tests/phpMyFAQ/Controller/Administration/Api/UserControllerTest.php @@ -1059,6 +1059,176 @@ public function testUpdateUserRightsReturnsSuccessWithValidCsrf(): void self::assertArrayHasKey('success', $payload); } + /** + * @throws \Exception + */ + public function testListUserLanguageRestrictionsRequiresUserPermission(): void + { + $request = new Request([], [], ['userId' => 1]); + $controller = $this->createController(); + + $this->expectException(\Exception::class); + $controller->listUserLanguageRestrictions($request); + } + + /** + * @throws \Exception + */ + public function testListUserLanguageRestrictionsReturnsEmptyWhenNoneSet(): void + { + $this->seedCurrentUserSession(); + $managedUserId = $this->seedManagedUser(); + + $controller = $this->createController(); + $controller->setContainer($this->createAuthenticatedContainer()); + + $response = $controller->listUserLanguageRestrictions( + new Request([], [], ['userId' => $managedUserId]), + ); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame([], $payload); + } + + /** + * @throws \Exception + */ + public function testSaveUserLanguageRestrictionsRequiresUserPermission(): void + { + $controller = $this->createController(); + + $this->expectException(\Exception::class); + $controller->saveUserLanguageRestrictions(new Request(content: '{}')); + } + + /** + * @throws \Exception + */ + public function testSaveUserLanguageRestrictionsReturnsUnauthorizedForInvalidCsrfWhenAuthenticated(): void + { + $this->seedCurrentUserSession(); + $managedUserId = $this->seedManagedUser(); + + $request = new Request([], [], [], [], [], [], json_encode([ + 'csrfToken' => 'invalid-token', + 'userId' => $managedUserId, + 'rightId' => 1, + 'languages' => ['en'], + ], JSON_THROW_ON_ERROR)); + + $controller = $this->createController(); + $controller->setContainer($this->createAuthenticatedContainer()); + + $response = $controller->saveUserLanguageRestrictions($request); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + self::assertSame(Translation::get('msgNoPermission'), $payload['error']); + } + + /** + * @throws \Exception + */ + public function testSaveUserLanguageRestrictionsReturnsBadRequestForMissingUserIdWithValidCsrf(): void + { + $this->seedCurrentUserSession(); + + $container = $this->createAuthenticatedContainer(); + $session = $container->get('session'); + self::assertInstanceOf(Session::class, $session); + $token = $this->createValidCsrfToken($session, 'update-user-language-restrictions'); + + $request = new Request([], [], [], [], [], [], json_encode([ + 'csrfToken' => $token, + 'userId' => 0, + 'rightId' => 1, + 'languages' => ['en'], + ], JSON_THROW_ON_ERROR)); + + $controller = $this->createController(); + $controller->setContainer($container); + + $response = $controller->saveUserLanguageRestrictions($request); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + self::assertSame(Translation::get('ad_user_error_noId'), $payload['error']); + } + + /** + * @throws \Exception + */ + public function testSaveUserLanguageRestrictionsSavesAndSkipsUnsupportedLanguagesWithValidCsrf(): void + { + $this->seedCurrentUserSession(); + $managedUserId = $this->seedManagedUser(); + + $container = $this->createAuthenticatedContainer(); + $session = $container->get('session'); + self::assertInstanceOf(Session::class, $session); + $token = $this->createValidCsrfToken($session, 'update-user-language-restrictions'); + + $request = new Request([], [], [], [], [], [], json_encode([ + 'csrfToken' => $token, + 'userId' => $managedUserId, + 'rightId' => 1, + 'languages' => ['en', 'not-a-language'], + ], JSON_THROW_ON_ERROR)); + + $controller = $this->createController(); + $controller->setContainer($container); + + $response = $controller->saveUserLanguageRestrictions($request); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertTrue($payload['success']); + + $listResponse = $controller->listUserLanguageRestrictions( + new Request([], [], ['userId' => $managedUserId]), + ); + $listPayload = json_decode((string) $listResponse->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertSame(['1' => ['en']], $listPayload); + } + + /** + * A non-SuperAdmin acting user restricted to 'de' for a right must not be able to grant + * another user 'fr' access for that same right (privilege escalation via language grant). + * + * @throws \Exception + */ + public function testSaveUserLanguageRestrictionsRejectsLanguageNotHeldByNonSuperAdmin(): void + { + $session = new Session(new MockArraySessionStorage()); + + $permission = $this->createMock(PermissionInterface::class); + $permission->method('hasPermission')->willReturn(true); + $permission->method('getAllowedLanguagesForRight')->willReturn(['de']); + + $actingUser = $this->createMock(CurrentUser::class); + $actingUser->perm = $permission; + $actingUser->method('isLoggedIn')->willReturn(true); + $actingUser->method('getUserId')->willReturn(5); + $actingUser->method('isSuperAdmin')->willReturn(false); + + $controller = $this->buildController($session, $actingUser); + $csrf = $this->primeCsrf($session, 'update-user-language-restrictions'); + + $request = $this->jsonRequest([ + 'csrfToken' => $csrf, + 'userId' => 1, + 'rightId' => 1, + 'languages' => ['fr'], + ]); + + $response = $controller->saveUserLanguageRestrictions($request); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + self::assertSame(Translation::get('msgNoPermission'), $payload['error']); + } + public function testEditUserNonSuperAdminCannotGrantSuperAdminFlag(): void { $session = new Session(new MockArraySessionStorage()); diff --git a/tests/phpMyFAQ/Controller/Administration/FaqControllerTest.php b/tests/phpMyFAQ/Controller/Administration/FaqControllerTest.php index 0012dc0977..fbbe59463b 100644 --- a/tests/phpMyFAQ/Controller/Administration/FaqControllerTest.php +++ b/tests/phpMyFAQ/Controller/Administration/FaqControllerTest.php @@ -320,6 +320,46 @@ public function testEditRendersTagsAndSeoData(): void self::assertStringContainsString('SEO description', (string) $response->getContent()); } + /** + * @throws \Exception + */ + public function testEditReturnsForbiddenForRestrictedLanguage(): void + { + $faq = $this->createMock(Faq::class); + $faq->faqRecord = [ + 'id' => 1, + 'lang' => 'fr', + 'title' => 'Prepared FAQ', + 'revision_id' => 0, + 'active' => 'yes', + 'author' => 'Test Author', + 'email' => 'test@example.com', + ]; + + $faqPermission = $this->createMock(FaqPermission::class); + $faqPermission->method('get')->willReturn([]); + + $controller = new FaqController( + $this->createStub(Comments::class), + $faq, + $this->createStub(Tags::class), + $this->createStub(Seo::class), + $this->createStub(CategoryHelper::class), + $this->createStub(UserHelper::class), + $faqPermission, + $this->createStub(Changelog::class), + $this->createStub(Question::class), + ); + + $request = new Request([], [], ['faqId' => '1', 'faqLanguage' => 'fr']); + $controller->setContainer($this->createAuthenticatedContainer()); + + $this->expectException(ForbiddenException::class); + $this->expectExceptionMessage('User has no "FAQ_EDIT" permission for language "fr".'); + + $controller->edit($request); + } + /** * @throws \Exception */ @@ -379,6 +419,13 @@ private function createAuthenticatedContainer(): ContainerInterface static fn(int $userId, mixed $right, int $categoryId): bool => $categoryId !== 666, ); $permission->method('getAllowedCategoriesForRight')->willReturn(null); + $permission + ->method('hasPermissionForLanguage') + ->willReturnCallback( + // sentinel forbidden language for tests + static fn(int $userId, mixed $right, string $language): bool => $language !== 'fr', + ); + $permission->method('getAllowedLanguagesForRight')->willReturn(null); $currentUser = $this->createMock(CurrentUser::class); $currentUser->perm = $permission; diff --git a/tests/phpMyFAQ/Language/LanguageRestrictionFilterTest.php b/tests/phpMyFAQ/Language/LanguageRestrictionFilterTest.php new file mode 100644 index 0000000000..f3b4b3d39b --- /dev/null +++ b/tests/phpMyFAQ/Language/LanguageRestrictionFilterTest.php @@ -0,0 +1,50 @@ + */ + private array $languages = [ + 'en' => 'English', + 'de' => 'Deutsch', + 'fr' => 'Français', + ]; + + public function testNullMeansUnrestricted(): void + { + $this->assertSame($this->languages, LanguageRestrictionFilter::filter($this->languages, null)); + } + + public function testKeepsOnlyAllowedLanguages(): void + { + $filtered = LanguageRestrictionFilter::filter($this->languages, ['en', 'fr']); + $this->assertSame(['en', 'fr'], array_keys($filtered)); + } + + public function testEmptyAllowListHidesEverything(): void + { + $this->assertSame([], LanguageRestrictionFilter::filter($this->languages, [])); + } + + public function testExcludedLanguagesIsEmptyWhenUnrestricted(): void + { + $this->assertSame([], LanguageRestrictionFilter::excludedLanguages($this->languages, null)); + } + + public function testExcludedLanguagesIsComplementOfAllowedSet(): void + { + $excluded = LanguageRestrictionFilter::excludedLanguages($this->languages, ['en', 'fr']); + $this->assertSame(['de'], $excluded); + } + + public function testExcludedLanguagesExcludesEverythingForEmptyAllowList(): void + { + $excluded = LanguageRestrictionFilter::excludedLanguages($this->languages, []); + $this->assertSame(['en', 'de', 'fr'], $excluded); + } +} diff --git a/tests/phpMyFAQ/Permission/BasicPermissionTest.php b/tests/phpMyFAQ/Permission/BasicPermissionTest.php index bf96c75182..21dc72a720 100644 --- a/tests/phpMyFAQ/Permission/BasicPermissionTest.php +++ b/tests/phpMyFAQ/Permission/BasicPermissionTest.php @@ -204,4 +204,52 @@ public function testGetAllowedCategoriesForRightIsAlwaysUnrestricted(): void { $this->assertNull($this->basicPermission->getAllowedCategoriesForRight(1, 1)); } + + public function testHasPermissionForLanguageWithNoRestrictionIsUnrestricted(): void + { + // Right 1 granted to user 1 in the fixture DB, no language restriction set + $this->assertTrue($this->basicPermission->hasPermissionForLanguage(1, 1, 'en')); + $this->assertTrue($this->basicPermission->hasPermissionForLanguage(1, 1, 'de')); + } + + public function testHasPermissionForLanguageDeniesWithoutGlobalRight(): void + { + $this->assertFalse($this->basicPermission->hasPermissionForLanguage(0, 999, 'en')); + } + + public function testHasPermissionForLanguageDeniesOtherLanguageWhenRestricted(): void + { + $this->dbHandle->query( + "INSERT INTO faquser_right_language (user_id, right_id, language) VALUES (1, 1, 'de')", + ); + + $this->assertTrue($this->basicPermission->hasPermissionForLanguage(1, 1, 'de')); + $this->assertFalse($this->basicPermission->hasPermissionForLanguage(1, 1, 'en')); + + // Cleanup + $this->dbHandle->query('DELETE FROM faquser_right_language WHERE user_id = 1 AND right_id = 1'); + } + + public function testGetAllowedLanguagesForRightReturnsNullWhenUnrestricted(): void + { + $this->assertNull($this->basicPermission->getAllowedLanguagesForRight(1, 1)); + } + + public function testGetAllowedLanguagesForRightReturnsRestrictedSet(): void + { + $this->dbHandle->query( + "INSERT INTO faquser_right_language (user_id, right_id, language) VALUES (1, 1, 'de')", + ); + + $allowed = $this->basicPermission->getAllowedLanguagesForRight(1, 1); + $this->assertSame(['de'], $allowed); + + // Cleanup + $this->dbHandle->query('DELETE FROM faquser_right_language WHERE user_id = 1 AND right_id = 1'); + } + + public function testGetAllowedLanguagesForRightReturnsEmptyArrayWithoutRight(): void + { + $this->assertSame([], $this->basicPermission->getAllowedLanguagesForRight(0, 999)); + } } diff --git a/tests/phpMyFAQ/Permission/LanguagePermissionRepositoryTest.php b/tests/phpMyFAQ/Permission/LanguagePermissionRepositoryTest.php new file mode 100644 index 0000000000..21f225b4ae --- /dev/null +++ b/tests/phpMyFAQ/Permission/LanguagePermissionRepositoryTest.php @@ -0,0 +1,321 @@ +getProperty('configuration'); + $this->previousConfiguration = $configurationProperty->getValue(); + + $databasePath = tempnam(sys_get_temp_dir(), 'pmf-lang-perm-'); + self::assertNotFalse($databasePath); + self::assertTrue(copy(PMF_TEST_DIR . '/test.db', $databasePath)); + $this->databasePath = $databasePath; + + $this->dbHandle = new Sqlite3(); + $this->dbHandle->connect($this->databasePath, '', ''); + $this->initializeDatabaseStatics($this->dbHandle); + $this->configuration = new Configuration($this->dbHandle); + + // Clean up test data + $this->dbHandle->query('DELETE FROM faquser_right_language'); + $this->dbHandle->query('DELETE FROM faqgroup_right_language'); + $this->dbHandle->query('DELETE FROM faqgroup_right'); + $this->dbHandle->query('DELETE FROM faquser_group'); + $this->dbHandle->query('DELETE FROM faqgroup'); + + $this->repository = new LanguagePermissionRepository($this->configuration); + } + + protected function tearDown(): void + { + $configurationReflection = new ReflectionClass(Configuration::class); + $configurationProperty = $configurationReflection->getProperty('configuration'); + $configurationProperty->setValue(null, $this->previousConfiguration); + + if (isset($this->dbHandle)) { + $this->dbHandle->close(); + } + + if (isset($this->databasePath) && is_file($this->databasePath)) { + unlink($this->databasePath); + } + + parent::tearDown(); + } + + public function testGetUserLanguageRestrictionsReturnsEmptyForInvalidInput(): void + { + $this->assertEmpty($this->repository->getUserLanguageRestrictions(0, 1)); + $this->assertEmpty($this->repository->getUserLanguageRestrictions(1, 0)); + $this->assertEmpty($this->repository->getUserLanguageRestrictions(-1, -1)); + } + + public function testGetUserLanguageRestrictionsReturnsEmptyWhenNoRestrictions(): void + { + $this->assertEmpty($this->repository->getUserLanguageRestrictions(1, 1)); + } + + public function testSetAndGetUserLanguageRestrictions(): void + { + $this->assertTrue($this->repository->setUserLanguageRestrictions(1, 1, ['en', 'de'])); + + $restrictions = $this->repository->getUserLanguageRestrictions(1, 1); + $this->assertCount(2, $restrictions); + $this->assertContains('en', $restrictions); + $this->assertContains('de', $restrictions); + } + + public function testSetUserLanguageRestrictionsReplacesExisting(): void + { + $this->assertTrue($this->repository->setUserLanguageRestrictions(1, 1, ['en', 'de'])); + $this->assertCount(2, $this->repository->getUserLanguageRestrictions(1, 1)); + + $this->assertTrue($this->repository->setUserLanguageRestrictions(1, 1, ['fr'])); + $restrictions = $this->repository->getUserLanguageRestrictions(1, 1); + $this->assertCount(1, $restrictions); + $this->assertContains('fr', $restrictions); + $this->assertNotContains('en', $restrictions); + } + + public function testSetUserLanguageRestrictionsWithEmptyArrayClearsRestrictions(): void + { + $this->assertTrue($this->repository->setUserLanguageRestrictions(1, 1, ['en', 'de'])); + $this->assertCount(2, $this->repository->getUserLanguageRestrictions(1, 1)); + + $this->assertTrue($this->repository->setUserLanguageRestrictions(1, 1, [])); + $this->assertEmpty($this->repository->getUserLanguageRestrictions(1, 1)); + } + + public function testSetUserLanguageRestrictionsSkipsUnsupportedLanguageCodes(): void + { + $this->assertTrue($this->repository->setUserLanguageRestrictions(1, 1, ['en', 'not-a-language'])); + + $restrictions = $this->repository->getUserLanguageRestrictions(1, 1); + $this->assertCount(1, $restrictions); + $this->assertContains('en', $restrictions); + } + + public function testSetUserLanguageRestrictionsReturnsFalseForInvalidInput(): void + { + $this->assertFalse($this->repository->setUserLanguageRestrictions(0, 1, ['en'])); + $this->assertFalse($this->repository->setUserLanguageRestrictions(1, 0, ['en'])); + } + + public function testDeleteUserLanguageRestrictions(): void + { + $this->repository->setUserLanguageRestrictions(1, 1, ['en', 'de']); + $this->repository->setUserLanguageRestrictions(1, 2, ['fr']); + + $this->assertTrue($this->repository->deleteUserLanguageRestrictions(1, 1)); + $this->assertEmpty($this->repository->getUserLanguageRestrictions(1, 1)); + $this->assertCount(1, $this->repository->getUserLanguageRestrictions(1, 2)); + } + + public function testDeleteUserLanguageRestrictionsReturnsFalseForInvalidInput(): void + { + $this->assertFalse($this->repository->deleteUserLanguageRestrictions(0, 1)); + $this->assertFalse($this->repository->deleteUserLanguageRestrictions(1, 0)); + } + + public function testDeleteAllForUser(): void + { + $this->repository->setUserLanguageRestrictions(1, 1, ['en']); + $this->repository->setUserLanguageRestrictions(1, 2, ['de']); + $this->repository->setUserLanguageRestrictions(2, 1, ['fr']); + + $this->assertTrue($this->repository->deleteAllForUser(1)); + $this->assertEmpty($this->repository->getUserLanguageRestrictions(1, 1)); + $this->assertEmpty($this->repository->getUserLanguageRestrictions(1, 2)); + $this->assertCount(1, $this->repository->getUserLanguageRestrictions(2, 1)); + } + + public function testDeleteAllForUserReturnsFalseForInvalidInput(): void + { + $this->assertFalse($this->repository->deleteAllForUser(0)); + } + + public function testCheckUserRightForLanguageWithNoRestrictions(): void + { + $this->dbHandle->query('INSERT INTO faquser_right (user_id, right_id) VALUES (1, 1)'); + + $this->assertTrue($this->repository->checkUserRightForLanguage(1, 1, 'en')); + $this->assertTrue($this->repository->checkUserRightForLanguage(1, 1, 'de')); + } + + public function testCheckUserRightForLanguageWithMatchingRestriction(): void + { + $this->repository->setUserLanguageRestrictions(1, 1, ['de', 'fr']); + + $this->assertTrue($this->repository->checkUserRightForLanguage(1, 1, 'de')); + $this->assertTrue($this->repository->checkUserRightForLanguage(1, 1, 'fr')); + $this->assertFalse($this->repository->checkUserRightForLanguage(1, 1, 'en')); + } + + public function testCheckUserRightForLanguageReturnsFalseForInvalidInput(): void + { + $this->assertFalse($this->repository->checkUserRightForLanguage(0, 1, 'en')); + $this->assertFalse($this->repository->checkUserRightForLanguage(1, 0, 'en')); + $this->assertFalse($this->repository->checkUserRightForLanguage(1, 1, 'not-a-language')); + } + + public function testGetLanguageRestrictionsReturnsEmptyForInvalidInput(): void + { + $this->assertEmpty($this->repository->getLanguageRestrictions(0, 1)); + $this->assertEmpty($this->repository->getLanguageRestrictions(1, 0)); + } + + public function testSetAndGetLanguageRestrictions(): void + { + $this->assertTrue($this->repository->setLanguageRestrictions(1, 1, ['en', 'de', 'fr'])); + + $restrictions = $this->repository->getLanguageRestrictions(1, 1); + $this->assertCount(3, $restrictions); + $this->assertContains('en', $restrictions); + $this->assertContains('de', $restrictions); + $this->assertContains('fr', $restrictions); + } + + public function testSetLanguageRestrictionsReturnsFalseForInvalidInput(): void + { + $this->assertFalse($this->repository->setLanguageRestrictions(0, 1, ['en'])); + $this->assertFalse($this->repository->setLanguageRestrictions(1, 0, ['en'])); + } + + public function testDeleteLanguageRestrictions(): void + { + $this->repository->setLanguageRestrictions(1, 1, ['en', 'de']); + $this->repository->setLanguageRestrictions(1, 2, ['fr']); + + $this->assertTrue($this->repository->deleteLanguageRestrictions(1, 1)); + $this->assertEmpty($this->repository->getLanguageRestrictions(1, 1)); + $this->assertCount(1, $this->repository->getLanguageRestrictions(1, 2)); + } + + public function testDeleteLanguageRestrictionsReturnsFalseForInvalidInput(): void + { + $this->assertFalse($this->repository->deleteLanguageRestrictions(0, 1)); + $this->assertFalse($this->repository->deleteLanguageRestrictions(1, 0)); + } + + public function testDeleteAllForGroup(): void + { + $this->repository->setLanguageRestrictions(1, 1, ['en', 'de']); + $this->repository->setLanguageRestrictions(1, 2, ['fr']); + $this->repository->setLanguageRestrictions(2, 1, ['es']); + + $this->assertTrue($this->repository->deleteAllForGroup(1)); + $this->assertEmpty($this->repository->getLanguageRestrictions(1, 1)); + $this->assertEmpty($this->repository->getLanguageRestrictions(1, 2)); + $this->assertCount(1, $this->repository->getLanguageRestrictions(2, 1)); + } + + public function testDeleteAllForGroupReturnsFalseForInvalidInput(): void + { + $this->assertFalse($this->repository->deleteAllForGroup(0)); + } + + public function testGetAllLanguageRestrictions(): void + { + $this->assertEmpty($this->repository->getAllLanguageRestrictions(0)); + + $this->repository->setLanguageRestrictions(1, 1, ['en', 'de']); + $this->repository->setLanguageRestrictions(1, 3, ['fr']); + + $all = $this->repository->getAllLanguageRestrictions(1); + $this->assertCount(2, $all); + $this->assertArrayHasKey(1, $all); + $this->assertArrayHasKey(3, $all); + $this->assertContains('en', $all[1]); + $this->assertContains('de', $all[1]); + $this->assertContains('fr', $all[3]); + } + + public function testCheckUserGroupRightForLanguageWithNoRestrictions(): void + { + $this->dbHandle->query( + "INSERT INTO faqgroup (group_id, name, description, auto_join) VALUES (1, 'TestGroup', 'Test', 0)", + ); + $this->dbHandle->query('INSERT INTO faquser_group (user_id, group_id) VALUES (1, 1)'); + $this->dbHandle->query('INSERT INTO faqgroup_right (group_id, right_id) VALUES (1, 1)'); + + // No language restrictions -> should have access to any language + $this->assertTrue($this->repository->checkUserGroupRightForLanguage(1, 1, 'en')); + } + + public function testCheckUserGroupRightForLanguageWithMatchingRestriction(): void + { + $this->dbHandle->query( + "INSERT INTO faqgroup (group_id, name, description, auto_join) VALUES (1, 'TestGroup', 'Test', 0)", + ); + $this->dbHandle->query('INSERT INTO faquser_group (user_id, group_id) VALUES (1, 1)'); + $this->dbHandle->query('INSERT INTO faqgroup_right (group_id, right_id) VALUES (1, 1)'); + + $this->repository->setLanguageRestrictions(1, 1, ['de', 'fr']); + + $this->assertTrue($this->repository->checkUserGroupRightForLanguage(1, 1, 'de')); + $this->assertTrue($this->repository->checkUserGroupRightForLanguage(1, 1, 'fr')); + $this->assertFalse($this->repository->checkUserGroupRightForLanguage(1, 1, 'en')); + } + + public function testCheckUserGroupRightForLanguageReturnsFalseForInvalidInput(): void + { + $this->assertFalse($this->repository->checkUserGroupRightForLanguage(0, 1, 'en')); + $this->assertFalse($this->repository->checkUserGroupRightForLanguage(1, 0, 'en')); + $this->assertFalse($this->repository->checkUserGroupRightForLanguage(1, 1, 'not-a-language')); + } + + public function testCheckUserGroupRightForLanguageWithMultipleGroups(): void + { + // Group 1: restricted to 'de' + $this->dbHandle->query( + "INSERT INTO faqgroup (group_id, name, description, auto_join) VALUES (1, 'Group1', 'Test', 0)", + ); + $this->dbHandle->query('INSERT INTO faqgroup_right (group_id, right_id) VALUES (1, 1)'); + $this->repository->setLanguageRestrictions(1, 1, ['de']); + + // Group 2: unrestricted (no language restrictions) + $this->dbHandle->query( + "INSERT INTO faqgroup (group_id, name, description, auto_join) VALUES (2, 'Group2', 'Test', 0)", + ); + $this->dbHandle->query('INSERT INTO faqgroup_right (group_id, right_id) VALUES (2, 1)'); + + // User in both groups + $this->dbHandle->query('INSERT INTO faquser_group (user_id, group_id) VALUES (1, 1)'); + $this->dbHandle->query('INSERT INTO faquser_group (user_id, group_id) VALUES (1, 2)'); + + // User should have access to any language because Group 2 is unrestricted + $this->assertTrue($this->repository->checkUserGroupRightForLanguage(1, 1, 'en')); + } + + private function initializeDatabaseStatics(Sqlite3 $dbHandle): void + { + $databaseReflection = new ReflectionClass(Database::class); + $databaseDriverProperty = $databaseReflection->getProperty('databaseDriver'); + $databaseDriverProperty->setValue(null, $dbHandle); + $dbTypeProperty = $databaseReflection->getProperty('dbType'); + $dbTypeProperty->setValue(null, 'sqlite3'); + Database::setTablePrefix(''); + } +} diff --git a/tests/phpMyFAQ/Permission/MediumPermissionTest.php b/tests/phpMyFAQ/Permission/MediumPermissionTest.php index 50611f5682..7fd0ab6acc 100644 --- a/tests/phpMyFAQ/Permission/MediumPermissionTest.php +++ b/tests/phpMyFAQ/Permission/MediumPermissionTest.php @@ -41,6 +41,8 @@ protected function setUp(): void $this->initializeDatabaseStatics($this->dbHandle); $this->configuration = new Configuration($this->dbHandle); $this->dbHandle->query('DELETE FROM faqgroup_right_category'); + $this->dbHandle->query('DELETE FROM faqgroup_right_language'); + $this->dbHandle->query('DELETE FROM faquser_right_language'); $this->dbHandle->query('DELETE FROM faqgroup_right'); $this->dbHandle->query('DELETE FROM faquser_group'); $this->dbHandle->query('DELETE FROM faqgroup'); @@ -752,6 +754,278 @@ private function assertConsistentWithPerCategoryChecks(int $userId, int $rightId } } + /** + * @throws Exception + */ + public function testHasPermissionForLanguageWithUnrestrictedGroupRight(): void + { + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 0 WHERE user_id = 1'); + $this->configuration->getDb()->query('DELETE FROM faquser_right WHERE user_id = 1 AND right_id = 1'); + + $this->mediumPermission->addGroup(['name' => 'TestGroup', 'description' => 'Test', 'auto_join' => false]); + $this->mediumPermission->addToGroup(1, 1); + $this->mediumPermission->grantGroupRight(1, 1); + + // No language restrictions -> should have access to any language + $this->assertTrue($this->mediumPermission->hasPermissionForLanguage(1, 1, 'en')); + + $this->mediumPermission->deleteGroup(1); + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 1 WHERE user_id = 1'); + $this->configuration->getDb()->query('INSERT INTO faquser_right (user_id, right_id) VALUES (1, 1)'); + } + + /** + * @throws Exception + */ + public function testHasPermissionForLanguageWithRestrictedGroupRight(): void + { + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 0 WHERE user_id = 1'); + $this->configuration->getDb()->query('DELETE FROM faquser_right WHERE user_id = 1 AND right_id = 1'); + + $this->mediumPermission->addGroup(['name' => 'TestGroup', 'description' => 'Test', 'auto_join' => false]); + $this->mediumPermission->addToGroup(1, 1); + $this->mediumPermission->grantGroupRight(1, 1); + $this->mediumPermission->setLanguageRestrictions(1, 1, ['de']); + + $this->assertTrue($this->mediumPermission->hasPermissionForLanguage(1, 1, 'de')); + $this->assertFalse($this->mediumPermission->hasPermissionForLanguage(1, 1, 'en')); + + $this->mediumPermission->deleteGroup(1); + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 1 WHERE user_id = 1'); + $this->configuration->getDb()->query('INSERT INTO faquser_right (user_id, right_id) VALUES (1, 1)'); + } + + /** + * @throws Exception + */ + public function testHasPermissionForLanguageWithRestrictedDirectUserGrantOnly(): void + { + // Fixture user 1 owns right 1 directly; restrict it to 'de' only, no groups involved + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 0 WHERE user_id = 1'); + $this->mediumPermission->setUserLanguageRestrictions(1, 1, ['de']); + + $this->assertTrue($this->mediumPermission->hasPermissionForLanguage(1, 1, 'de')); + $this->assertFalse($this->mediumPermission->hasPermissionForLanguage(1, 1, 'en')); + + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 1 WHERE user_id = 1'); + $this->mediumPermission->setUserLanguageRestrictions(1, 1, []); + } + + /** + * @throws Exception + */ + public function testHasPermissionForLanguageCombinesUserAndGroupGrantsAsUnion(): void + { + // Fixture user 1 owns right 1 directly, restricted to 'de' + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 0 WHERE user_id = 1'); + $this->mediumPermission->setUserLanguageRestrictions(1, 1, ['de']); + + // Also a member of a group holding right 1, restricted to 'fr' + $this->mediumPermission->addGroup(['name' => 'TestGroup', 'description' => 'Test', 'auto_join' => false]); + $this->mediumPermission->addToGroup(1, 1); + $this->mediumPermission->grantGroupRight(1, 1); + $this->mediumPermission->setLanguageRestrictions(1, 1, ['fr']); + + // Union of both grants: 'de' (direct) and 'fr' (group) both pass, 'es' fails + $this->assertTrue($this->mediumPermission->hasPermissionForLanguage(1, 1, 'de')); + $this->assertTrue($this->mediumPermission->hasPermissionForLanguage(1, 1, 'fr')); + $this->assertFalse($this->mediumPermission->hasPermissionForLanguage(1, 1, 'es')); + + $this->mediumPermission->deleteGroup(1); + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 1 WHERE user_id = 1'); + $this->mediumPermission->setUserLanguageRestrictions(1, 1, []); + } + + public function testGetAndSetLanguageRestrictions(): void + { + $groupData = [ + 'name' => 'TestGroup', + 'description' => 'TestDescription', + 'auto_join' => false, + ]; + $this->mediumPermission->addGroup($groupData); + + $this->assertEmpty($this->mediumPermission->getLanguageRestrictions(1, 1)); + + $this->assertTrue($this->mediumPermission->setLanguageRestrictions(1, 1, ['en', 'de'])); + $restrictions = $this->mediumPermission->getLanguageRestrictions(1, 1); + $this->assertCount(2, $restrictions); + $this->assertContains('en', $restrictions); + $this->assertContains('de', $restrictions); + + $this->mediumPermission->deleteGroup(1); + } + + public function testGetAndSetUserLanguageRestrictions(): void + { + $this->assertEmpty($this->mediumPermission->getUserLanguageRestrictions(1, 1)); + + $this->assertTrue($this->mediumPermission->setUserLanguageRestrictions(1, 1, ['en', 'de'])); + $restrictions = $this->mediumPermission->getUserLanguageRestrictions(1, 1); + $this->assertCount(2, $restrictions); + $this->assertContains('en', $restrictions); + $this->assertContains('de', $restrictions); + + $this->mediumPermission->setUserLanguageRestrictions(1, 1, []); + } + + public function testGetAllLanguageRestrictions(): void + { + $groupData = [ + 'name' => 'TestGroup', + 'description' => 'TestDescription', + 'auto_join' => false, + ]; + $this->mediumPermission->addGroup($groupData); + + $this->mediumPermission->setLanguageRestrictions(1, 1, ['en']); + $this->mediumPermission->setLanguageRestrictions(1, 2, ['de', 'fr']); + + $all = $this->mediumPermission->getAllLanguageRestrictions(1); + $this->assertCount(2, $all); + $this->assertArrayHasKey(1, $all); + $this->assertArrayHasKey(2, $all); + + $this->mediumPermission->deleteGroup(1); + } + + public function testDeleteGroupCleansLanguageRestrictions(): void + { + $groupData = [ + 'name' => 'TestGroup', + 'description' => 'TestDescription', + 'auto_join' => false, + ]; + $this->mediumPermission->addGroup($groupData); + $this->mediumPermission->setLanguageRestrictions(1, 1, ['en', 'de']); + + $this->assertTrue($this->mediumPermission->deleteGroup(1)); + $this->assertEmpty($this->mediumPermission->getAllLanguageRestrictions(1)); + } + + /** + * @throws Exception + */ + public function testGetAllowedLanguagesForRightReturnsNullForUnrestrictedGroupRight(): void + { + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 0 WHERE user_id = 1'); + $this->configuration->getDb()->query('DELETE FROM faquser_right WHERE user_id = 1 AND right_id = 1'); + + $this->mediumPermission->addGroup(['name' => 'TestGroup', 'description' => 'Test', 'auto_join' => false]); + $this->mediumPermission->addToGroup(1, 1); + $this->mediumPermission->grantGroupRight(1, 1); + + $this->assertNull($this->mediumPermission->getAllowedLanguagesForRight(1, 1)); + + $this->mediumPermission->deleteGroup(1); + } + + /** + * @throws Exception + */ + public function testGetAllowedLanguagesForRightReturnsRestrictedLanguages(): void + { + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 0 WHERE user_id = 1'); + $this->configuration->getDb()->query('DELETE FROM faquser_right WHERE user_id = 1 AND right_id = 1'); + + $this->mediumPermission->addGroup(['name' => 'TestGroup', 'description' => 'Test', 'auto_join' => false]); + $this->mediumPermission->addToGroup(1, 1); + $this->mediumPermission->grantGroupRight(1, 1); + $this->mediumPermission->setLanguageRestrictions(1, 1, ['en', 'de']); + + $this->assertEqualsCanonicalizing(['en', 'de'], $this->mediumPermission->getAllowedLanguagesForRight(1, 1)); + + $this->mediumPermission->deleteGroup(1); + } + + /** + * @throws Exception + */ + public function testGetAllowedLanguagesForRightReturnsNullForUnrestrictedDirectUserRight(): void + { + // Fixture user 1 owns right 1 directly (faquser_right), unrestricted => global + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 0 WHERE user_id = 1'); + + $this->assertNull($this->mediumPermission->getAllowedLanguagesForRight(1, 1)); + } + + /** + * @throws Exception + */ + public function testGetAllowedLanguagesForRightReturnsUnionAcrossUserAndGroups(): void + { + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 0 WHERE user_id = 1'); + $this->mediumPermission->setUserLanguageRestrictions(1, 1, ['de']); + + $this->mediumPermission->addGroup(['name' => 'TestGroup', 'description' => 'Test', 'auto_join' => false]); + $this->mediumPermission->addToGroup(1, 1); + $this->mediumPermission->grantGroupRight(1, 1); + $this->mediumPermission->setLanguageRestrictions(1, 1, ['fr']); + + $this->assertSame(['de', 'fr'], $this->mediumPermission->getAllowedLanguagesForRight(1, 1)); + + $this->mediumPermission->deleteGroup(1); + $this->mediumPermission->setUserLanguageRestrictions(1, 1, []); + } + + /** + * @throws Exception + */ + public function testGetAllowedLanguagesForRightReturnsEmptyArrayWithoutAnyGrant(): void + { + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 0 WHERE user_id = 1'); + $this->configuration->getDb()->query('DELETE FROM faquser_right WHERE user_id = 1 AND right_id = 1'); + + $this->assertSame([], $this->mediumPermission->getAllowedLanguagesForRight(1, 1)); + } + + /** + * Pins that getAllowedLanguagesForRight() and hasPermissionForLanguage() + * agree for every language: a language is in the allowed set (or the set + * is null) exactly when the per-language check grants it. + * + * @throws Exception + */ + public function testGetAllowedLanguagesForRightMatchesPerLanguageChecks(): void + { + $this->configuration->getDb()->query('UPDATE faquser SET is_superadmin = 0 WHERE user_id = 1'); + $this->mediumPermission->setUserLanguageRestrictions(1, 1, ['en', 'de']); + + $this->mediumPermission->addGroup(['name' => 'TestGroup', 'description' => 'Test', 'auto_join' => false]); + $this->mediumPermission->addToGroup(1, 1); + $this->mediumPermission->grantGroupRight(1, 1); + $this->mediumPermission->setLanguageRestrictions(1, 1, ['de', 'fr']); + + $languages = ['en', 'de', 'fr', 'es']; + + // Both grants restricted: allowed set is the union + $this->assertConsistentWithPerLanguageChecks(1, 1, $languages); + + // Group grant becomes unrestricted: the right applies globally + $this->mediumPermission->setLanguageRestrictions(1, 1, []); + $this->assertNull($this->mediumPermission->getAllowedLanguagesForRight(1, 1)); + $this->assertConsistentWithPerLanguageChecks(1, 1, $languages); + + $this->mediumPermission->deleteGroup(1); + $this->mediumPermission->setUserLanguageRestrictions(1, 1, []); + } + + /** + * @param array $languages + * @throws Exception + */ + private function assertConsistentWithPerLanguageChecks(int $userId, int $rightId, array $languages): void + { + $allowed = $this->mediumPermission->getAllowedLanguagesForRight($userId, $rightId); + foreach ($languages as $language) { + $this->assertSame( + $allowed === null || in_array($language, $allowed, strict: true), + $this->mediumPermission->hasPermissionForLanguage($userId, $rightId, $language), + sprintf('Mismatch for language %s (allowed: %s)', $language, json_encode($allowed)), + ); + } + } + private function initializeDatabaseStatics(Sqlite3 $dbHandle): void { $databaseReflection = new ReflectionClass(Database::class); diff --git a/tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php b/tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php index b953a34ae1..e9bae9cebf 100644 --- a/tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php +++ b/tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php @@ -12,7 +12,7 @@ class DatabaseSchemaTest extends TestCase { - private const EXPECTED_TABLE_COUNT = 53; + private const EXPECTED_TABLE_COUNT = 55; /** * @return array @@ -46,6 +46,8 @@ public function testGetTableNamesReturnsCorrectNames(DialectInterface $dialect): $this->assertContains('faquser', $names); $this->assertContains('faqconfig', $names); $this->assertContains('faqgroup_right_category', $names); + $this->assertContains('faquser_right_language', $names); + $this->assertContains('faqgroup_right_language', $names); $this->assertContains('faqapi_keys', $names); $this->assertContains('faqoauth_clients', $names); $this->assertContains('faqoauth_access_tokens', $names); diff --git a/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php b/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php index 4375f2e848..a8f73506eb 100644 --- a/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php +++ b/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php @@ -50,7 +50,7 @@ public function testDryRunCollectsAllSql(DialectInterface $dialect): void $createTableCount++; } } - $this->assertEquals(53, $createTableCount, 'Should generate CREATE TABLE for all 53 tables'); + $this->assertEquals(55, $createTableCount, 'Should generate CREATE TABLE for all 55 tables'); } #[DataProvider('dialectProvider')] diff --git a/tests/phpMyFAQ/Setup/Migration/MigrationRegistryTest.php b/tests/phpMyFAQ/Setup/Migration/MigrationRegistryTest.php index 0335421f0e..4f1cfa85b4 100644 --- a/tests/phpMyFAQ/Setup/Migration/MigrationRegistryTest.php +++ b/tests/phpMyFAQ/Setup/Migration/MigrationRegistryTest.php @@ -42,6 +42,7 @@ public function testGetVersionsContainsExpectedVersions(): void $this->assertContains('3.2.0-alpha', $versions); $this->assertContains('4.0.0-alpha', $versions); $this->assertContains('4.2.0-alpha', $versions); + $this->assertContains('4.2.0-alpha.2', $versions); } public function testGetVersionsAreSorted(): void @@ -92,7 +93,7 @@ public function testGetLatestVersionReturnsLastVersion(): void $latestVersion = $this->registry->getLatestVersion(); $this->assertNotNull($latestVersion); - $this->assertEquals('4.2.0-alpha', $latestVersion); + $this->assertEquals('4.2.0-alpha.2', $latestVersion); } public function testGetPendingMigrationsFromOldVersion(): void @@ -105,7 +106,7 @@ public function testGetPendingMigrationsFromOldVersion(): void public function testGetPendingMigrationsFromCurrentVersion(): void { - $pending = $this->registry->getPendingMigrations('4.2.0-alpha'); + $pending = $this->registry->getPendingMigrations('4.2.0-alpha.2'); $this->assertEmpty($pending); } @@ -118,6 +119,7 @@ public function testGetPendingMigrationsFromMidVersion(): void $this->assertArrayHasKey('4.0.5', $pending); $this->assertArrayHasKey('4.1.0-alpha', $pending); $this->assertArrayHasKey('4.2.0-alpha', $pending); + $this->assertArrayHasKey('4.2.0-alpha.2', $pending); // Should not include versions before or equal to 4.0.0 $this->assertArrayNotHasKey('3.2.0-alpha', $pending); From 745e2cb114cca8c9c8a0661e0aa71c13ab3db265 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 10 Aug 2026 19:38:03 +0200 Subject: [PATCH 2/2] fix: resolved mago analyze errors from language-permission changes --- .../Controller/Administration/Api/GroupController.php | 10 ++++------ phpmyfaq/src/phpMyFAQ/Helper/LanguageHelper.php | 2 +- .../Permission/LanguagePermissionRepository.php | 1 + 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/GroupController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/GroupController.php index c312f37ab6..76658c7322 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/GroupController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/GroupController.php @@ -318,12 +318,10 @@ public function listLanguages(): JsonResponse { $this->userHasGroupPermission(); - $availableLanguages = LanguageHelper::getAvailableLanguages(); - $languages = array_map( - static fn(string $code, string $label): array => ['code' => $code, 'label' => $label], - array_keys($availableLanguages), - $availableLanguages, - ); + $languages = []; + foreach (LanguageHelper::getAvailableLanguages() as $code => $label) { + $languages[] = ['code' => $code, 'label' => $label]; + } return $this->json($languages, Response::HTTP_OK); } diff --git a/phpmyfaq/src/phpMyFAQ/Helper/LanguageHelper.php b/phpmyfaq/src/phpMyFAQ/Helper/LanguageHelper.php index e1cb98e208..6b1e823b7f 100644 --- a/phpmyfaq/src/phpMyFAQ/Helper/LanguageHelper.php +++ b/phpmyfaq/src/phpMyFAQ/Helper/LanguageHelper.php @@ -74,7 +74,7 @@ public static function renderSelectLanguage( /** * This function returns the available languages. * - * @return string[] + * @return array */ public static function getAvailableLanguages(): array { diff --git a/phpmyfaq/src/phpMyFAQ/Permission/LanguagePermissionRepository.php b/phpmyfaq/src/phpMyFAQ/Permission/LanguagePermissionRepository.php index f62dfb4fb3..4f2d4235ea 100644 --- a/phpmyfaq/src/phpMyFAQ/Permission/LanguagePermissionRepository.php +++ b/phpmyfaq/src/phpMyFAQ/Permission/LanguagePermissionRepository.php @@ -382,6 +382,7 @@ private function fetchLanguageColumn(string $select): array * supported language code, built via $rowBuilder. Runs inside a transaction so * the replace is atomic. Unsupported language codes are silently skipped. * + * @param callable(string): string $rowBuilder * @param array $languages */ private function replaceLanguageRows(