Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.09% covered (success)
97.09%
200 / 206
83.33% covered (success)
83.33%
15 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Tags
97.09% covered (success)
97.09%
200 / 206
83.33% covered (success)
83.33%
15 / 18
57
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setGroups
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setBypassPermissionCheck
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAllLinkTagsById
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getAllTagsById
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 create
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
8
 getAllTags
93.55% covered (success)
93.55%
29 / 31
0.00% covered (danger)
0.00%
0 / 1
10.03
 deleteByRecordId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getFaqsByIntersectionTags
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 getFaqsByTagId
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getPopularTags
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 getTagNameById
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getPopularTagsAsArray
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 buildPermissionCheck
81.82% covered (success)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 normalizePermissionGroups
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * The main Tags class.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @author    Matteo Scaramuccia <matteo@scaramuccia.com>
13 * @author    Georgi Korchev <korchev@yahoo.com>
14 * @copyright 2006-2026 phpMyFAQ Team
15 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 * @since     2006-08-10
18 */
19
20declare(strict_types=1);
21
22namespace phpMyFAQ;
23
24use phpMyFAQ\Entity\Tag;
25
26/**
27 * Class Tags
28 *
29 * Manages FAQ tags with support for permission-based filtering.
30 * Tags are filtered based on user and group permissions to ensure
31 * that only tags from FAQs the current user can access are displayed.
32 *
33 * @package phpMyFAQ
34 */
35class Tags
36{
37    private int $user = -1;
38
39    /** @var array<int> */
40    private array $groups = [-1];
41
42    private bool $bypassPermissionCheck = false;
43
44    /**
45     * Constructor.
46     */
47    public function __construct(
48        private readonly Configuration $configuration,
49    ) {
50    }
51
52    /**
53     * Sets the user.
54     */
55    public function setUser(int $userId = -1): Tags
56    {
57        $this->user = $userId;
58        return $this;
59    }
60
61    /**
62     * Sets the groups.
63     *
64     * @param array<int> $groups Array of group IDs
65     */
66    public function setGroups(array $groups): Tags
67    {
68        // Ensure all values are integers for security
69        $this->groups = array_map(intval(...), $groups);
70        return $this;
71    }
72
73    /**
74     * Disables permission filtering. Use in admin contexts where the
75     * caller has already enforced authorization and must see all tags.
76     */
77    public function setBypassPermissionCheck(bool $bypass = true): Tags
78    {
79        $this->bypassPermissionCheck = $bypass;
80        return $this;
81    }
82
83    /**
84     * Returns all tags for a FAQ record.
85     *
86     * @param int $recordId Record ID
87     */
88    public function getAllLinkTagsById(int $recordId): string
89    {
90        $tagListing = '';
91
92        foreach ($this->getAllTagsById($recordId) as $taggingId => $taggingName) {
93            $title = Strings::htmlentities($taggingName);
94            $url = sprintf('%ssearch.html?tagging_id=%d', $this->configuration->getDefaultUrl(), $taggingId);
95            $oLink = new Link($url, $this->configuration);
96            $oLink->setTitle($title);
97            $oLink->text = $title;
98            $oLink->tooltip = $title;
99            $oLink->class = 'btn btn-outline-primary';
100            $tagListing .= $oLink->toHtmlAnchor() . ' ';
101        }
102
103        return '' === $tagListing ? '-' : Strings::substr($tagListing, 0, -1);
104    }
105
106    /**
107     * Returns all tags for a FAQ record.
108     *
109     * @param int $recordId Record ID
110     * @return array<int, string>
111     */
112    public function getAllTagsById(int $recordId): array
113    {
114        $tags = [];
115
116        $query = sprintf(
117            '
118            SELECT
119                dt.tagging_id AS tagging_id, 
120                t.tagging_name AS tagging_name
121            FROM
122                %sfaqdata_tags dt, %sfaqtags t
123            WHERE
124                dt.record_id = %d
125            AND
126                dt.tagging_id = t.tagging_id
127            ORDER BY
128                t.tagging_name',
129            Database::getTablePrefix(),
130            Database::getTablePrefix(),
131            $recordId,
132        );
133
134        $result = $this->configuration->getDb()->query($query);
135        if ($result) {
136            while (true) {
137                $row = $this->configuration->getDb()->fetchObject($result);
138                if (!$row instanceof \stdClass) {
139                    break;
140                }
141
142                $tags[(int) $row->tagging_id] = (string) $row->tagging_name;
143            }
144        }
145
146        return $tags;
147    }
148
149    /**
150     * Saves all tags from a FAQ record.
151     *
152     * @param int $recordId Record ID
153     * @param array<int, string> $tags Array of tags
154     */
155    public function create(int $recordId, array $tags): bool
156    {
157        $currentTags = $this->getAllTags();
158        $registeredTags = [];
159
160        // Delete all tag references for the faq record
161        if ($tags !== []) {
162            $this->deleteByRecordId($recordId);
163        }
164
165        // Store tags and references for the faq record
166        foreach ($tags as $tag) {
167            $tag = trim($tag);
168            if (Strings::strlen($tag) > 0 && !in_array($tag, $registeredTags, strict: true)) {
169                $query = '';
170                $existingTagId = array_search(
171                    Strings::strtolower($tag),
172                    array_map(['phpMyFAQ\Strings', 'strtolower'], $currentTags),
173                    strict: true,
174                );
175
176                if ($existingTagId === false) {
177                    // Create the new tag
178                    $newTagId = $this->configuration->getDb()->nextId(
179                        Database::getTablePrefix() . 'faqtags',
180                        'tagging_id',
181                    );
182                    $query = sprintf(
183                        "INSERT INTO %sfaqtags (tagging_id, tagging_name) VALUES (%d, '%s')",
184                        Database::getTablePrefix(),
185                        $newTagId,
186                        $this->configuration->getDb()->escape($tag),
187                    );
188                    $this->configuration->getDb()->query($query);
189
190                    // Add the tag reference for the faq record
191                    $query = sprintf(
192                        'INSERT INTO %sfaqdata_tags (record_id, tagging_id) VALUES (%d, %d)',
193                        Database::getTablePrefix(),
194                        $recordId,
195                        $newTagId,
196                    );
197                }
198
199                if ($existingTagId !== false) {
200                    // Add the tag reference for the faq record
201                    $query = sprintf(
202                        'INSERT INTO %sfaqdata_tags (record_id, tagging_id) VALUES (%d, %d)',
203                        Database::getTablePrefix(),
204                        $recordId,
205                        $existingTagId,
206                    );
207                }
208
209                if ($query !== '') {
210                    $this->configuration->getDb()->query($query);
211                }
212
213                $registeredTags[] = $tag;
214            }
215        }
216
217        return true;
218    }
219
220    /**
221     * Returns all tags.
222     *
223     * @param string|null $search Move the returned result set to be the result of a start-with search
224     * @param int $limit Limit the returned result set
225     * @param bool $showInactive Show inactive tags
226     * @return array<int, string>
227     */
228    public function getAllTags(
229        ?string $search = null,
230        int $limit = PMF_TAGS_CLOUD_RESULT_SET_SIZE,
231        bool $showInactive = false,
232    ): array {
233        $allTags = [];
234
235        $like = match (Database::getType()) {
236            'pgsql' => 'ILIKE',
237            default => 'LIKE',
238        };
239
240        // Build permission check for user and groups
241        $permissionCheck = $this->buildPermissionCheck();
242
243        $query = sprintf(
244            '
245            SELECT
246                MIN(t.tagging_id) AS tagging_id, t.tagging_name AS tagging_name
247            FROM
248                %sfaqtags t
249            LEFT JOIN
250                %sfaqdata_tags dt
251            ON
252                dt.tagging_id = t.tagging_id
253            LEFT JOIN
254                %sfaqdata d
255            ON
256                d.id = dt.record_id
257            LEFT JOIN
258                %sfaqdata_user fdu
259            ON
260                d.id = fdu.record_id
261            LEFT JOIN
262                %sfaqdata_group fdg
263            ON
264                d.id = fdg.record_id
265            WHERE
266                1=1
267                %s
268                %s
269                %s
270            GROUP BY
271                tagging_name
272            ORDER BY
273                tagging_name ASC',
274            Database::getTablePrefix(),
275            Database::getTablePrefix(),
276            Database::getTablePrefix(),
277            Database::getTablePrefix(),
278            Database::getTablePrefix(),
279            $showInactive ? '' : "AND d.active = 'yes'",
280            $search !== null && $search !== ''
281                ? 'AND tagging_name ' . $like . " '" . $this->configuration->getDb()->escape($search) . "%'"
282                : '',
283            $permissionCheck,
284        );
285
286        $result = $this->configuration->getDb()->query($query);
287
288        if ($result) {
289            $i = 0;
290            while (true) {
291                $row = $this->configuration->getDb()->fetchObject($result);
292                if (!$row instanceof \stdClass) {
293                    break;
294                }
295
296                if ($i >= $limit) {
297                    break;
298                }
299
300                $allTags[(int) $row->tagging_id] = (string) $row->tagging_name;
301                ++$i;
302            }
303        }
304
305        return array_unique($allTags);
306    }
307
308    /**
309     * Deletes all tags from a given record id.
310     *
311     * @param int $recordId Record ID
312     */
313    public function deleteByRecordId(int $recordId): bool
314    {
315        $query = sprintf('DELETE FROM %sfaqdata_tags WHERE record_id = %d', Database::getTablePrefix(), $recordId);
316
317        return (bool) $this->configuration->getDb()->query($query);
318    }
319
320    /**
321     * Updates a tag.
322     */
323    public function update(Tag $tag): bool
324    {
325        $query = sprintf(
326            "UPDATE %sfaqtags SET tagging_name = '%s' WHERE tagging_id = %d",
327            Database::getTablePrefix(),
328            $this->configuration->getDb()->escape($tag->getName()),
329            $tag->getId(),
330        );
331
332        return (bool) $this->configuration->getDb()->query($query);
333    }
334
335    /**
336     * Deletes a given tag.
337     */
338    public function delete(int $tagId): bool
339    {
340        $query = sprintf('DELETE FROM %sfaqtags WHERE tagging_id = %d', Database::getTablePrefix(), $tagId);
341
342        $this->configuration->getDb()->query($query);
343
344        $query = sprintf('DELETE FROM %sfaqdata_tags WHERE tagging_id = %d', Database::getTablePrefix(), $tagId);
345
346        return (bool) $this->configuration->getDb()->query($query);
347    }
348
349    /**
350     * Returns the FAQ record IDs where all tags are included.
351     *
352     * @param array<int, string> $arrayOfTags Array of tag names
353     * @return array<int, int>
354     */
355    public function getFaqsByIntersectionTags(array $arrayOfTags): array
356    {
357        $db = $this->configuration->getDb();
358        $escapedTags = array_map(static fn($tag): string => $db->escape((string) $tag), $arrayOfTags);
359
360        $query = sprintf(
361            "
362            SELECT
363                td.record_id AS record_id
364            FROM
365                %sfaqdata_tags td
366            JOIN
367                %sfaqtags t ON (td.tagging_id = t.tagging_id)
368            JOIN
369                %sfaqdata d ON (td.record_id = d.id)
370            WHERE
371                (t.tagging_name IN ('%s'))
372            AND
373                (d.lang = '%s')
374            GROUP BY
375                td.record_id
376            HAVING
377                COUNT(td.record_id) = %d",
378            Database::getTablePrefix(),
379            Database::getTablePrefix(),
380            Database::getTablePrefix(),
381            implode("', '", $escapedTags),
382            $db->escape($this->configuration->getLanguage()->getLanguage()),
383            count($arrayOfTags),
384        );
385
386        $records = [];
387        $result = $this->configuration->getDb()->query($query);
388        if ($result) {
389            while (true) {
390                $row = $this->configuration->getDb()->fetchObject($result);
391                if (!$row instanceof \stdClass) {
392                    break;
393                }
394
395                $records[] = (int) $row->record_id;
396            }
397        }
398
399        return $records;
400    }
401
402    /**
403     * Returns all FAQ record IDs where all tags are included.
404     *
405     * @param int $tagId Tagging ID
406     * @return array<int>
407     */
408    public function getFaqsByTagId(int $tagId): array
409    {
410        $query = sprintf('
411            SELECT
412                d.record_id AS record_id
413            FROM
414                %sfaqdata_tags d, %sfaqtags t
415            WHERE
416                t.tagging_id = d.tagging_id
417            AND
418                t.tagging_id = %d
419            GROUP BY
420                record_id', Database::getTablePrefix(), Database::getTablePrefix(), $tagId);
421
422        $records = [];
423        $result = $this->configuration->getDb()->query($query);
424        if ($result) {
425            while (true) {
426                $row = $this->configuration->getDb()->fetchObject($result);
427                if (!$row instanceof \stdClass) {
428                    break;
429                }
430
431                $records[] = (int) $row->record_id;
432            }
433        }
434
435        return $records;
436    }
437
438    /**
439     * @param int $limit Specify the maximum number of records to return
440     * @return array<int, int>
441     */
442    public function getPopularTags(int $limit = 0): array
443    {
444        $tags = [];
445
446        // Build permission check for user and groups
447        $permissionCheck = $this->buildPermissionCheck();
448
449        $query = sprintf(
450            "
451            SELECT
452                COUNT(dt.record_id) as freq, dt.tagging_id
453            FROM
454                %sfaqdata_tags dt
455            JOIN
456                %sfaqdata d ON d.id = dt.record_id
457            LEFT JOIN
458                %sfaqdata_user fdu ON d.id = fdu.record_id
459            LEFT JOIN
460                %sfaqdata_group fdg ON d.id = fdg.record_id
461            WHERE
462                d.lang = '%s'
463                AND d.active = 'yes'
464                %s
465            GROUP BY dt.tagging_id
466            ORDER BY freq DESC",
467            Database::getTablePrefix(),
468            Database::getTablePrefix(),
469            Database::getTablePrefix(),
470            Database::getTablePrefix(),
471            $this->configuration->getLanguage()->getLanguage(),
472            $permissionCheck,
473        );
474
475        $result = $this->configuration->getDb()->query($query);
476
477        if ($result) {
478            while (true) {
479                $row = $this->configuration->getDb()->fetchObject($result);
480                if (!$row instanceof \stdClass) {
481                    break;
482                }
483
484                $tags[(int) $row->tagging_id] = (int) $row->freq;
485                if (--$limit === 0) {
486                    break;
487                }
488            }
489        }
490
491        return $tags;
492    }
493
494    /**
495     * Returns the tagged item.
496     *
497     * @param int $tagId Tagging ID
498     */
499    public function getTagNameById(int $tagId): string
500    {
501        $query = sprintf(
502            'SELECT tagging_name FROM %sfaqtags WHERE tagging_id = %d',
503            Database::getTablePrefix(),
504            $tagId,
505        );
506
507        $result = $this->configuration->getDb()->query($query);
508        $row = $result ? $this->configuration->getDb()->fetchObject($result) : false;
509        if ($row instanceof \stdClass) {
510            return (string) $row->tagging_name;
511        }
512
513        return '';
514    }
515
516    /**
517     * Returns the popular Tags as an array
518     *
519     * @return array<int, array<string, int|string>>
520     */
521    public function getPopularTagsAsArray(int $limit = 0): array
522    {
523        $data = [];
524        foreach ($this->getPopularTags($limit) as $tagId => $tagFreq) {
525            $tagName = $this->getTagNameById($tagId);
526            $data[] = [
527                'tagId' => $tagId,
528                'tagName' => $tagName,
529                'tagFrequency' => $tagFreq,
530            ];
531        }
532
533        return $data;
534    }
535
536    /**
537     * Builds the permission check SQL clause based on user and groups.
538     * This ensures that only tags from FAQs that the current user has permission to view are included.
539     *
540     * @return string SQL WHERE clause for permission filtering
541     */
542    private function buildPermissionCheck(): string
543    {
544        if ($this->bypassPermissionCheck) {
545            return '';
546        }
547
548        $groupSupport = $this->configuration->get(item: 'security.permLevel') !== 'basic';
549        $groupList = $this->normalizePermissionGroups();
550
551        if ($groupSupport) {
552            if (-1 === $this->user) {
553                // Only group permissions apply (anonymous user)
554                return sprintf('AND fdg.group_id IN (%s)', $groupList);
555            }
556
557            // Check both user and group permissions
558            return sprintf('AND ( fdu.user_id = %d OR fdg.group_id IN (%s) )', (int) $this->user, $groupList);
559        }
560
561        // Basic permission level - only user permissions
562        if (-1 !== $this->user) {
563            return sprintf('AND ( fdu.user_id = %d OR fdu.user_id = -1 )', (int) $this->user);
564        }
565
566        // Anonymous user with basic permission level
567        return 'AND fdu.user_id = -1';
568    }
569
570    private function normalizePermissionGroups(): string
571    {
572        $normalizedGroups = array_map(static fn($group): int => (int) $group, $this->groups);
573
574        return $normalizedGroups === [] ? '-1' : implode(', ', $normalizedGroups);
575    }
576}