Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
RecordVisibility
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
5 / 5
11
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isVisible
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 isPermitted
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isActivated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWithinPublicationWindow
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Visibility of a single FAQ record on public read paths.
7 *
8 * This Source Code Form is subject to the terms of the Mozilla Public License,
9 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
10 * obtain one at https://mozilla.org/MPL/2.0/.
11 *
12 * @package   phpMyFAQ
13 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
14 * @copyright 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     2026-08-02
18 */
19
20namespace phpMyFAQ\Faq;
21
22/**
23 * Answers whether an FAQ record may be disclosed to the current requester: permitted,
24 * activated by an editor and inside its publication window.
25 *
26 * Neither Faq::getFaq() nor Faq::getFaqBySolutionId() filters by publication state. An
27 * inactive, not yet published or already expired record comes back with its metadata
28 * intact and only the answer replaced by a placeholder, and a non-existing or
29 * non-permitted record is flagged with the solution ID 42. Public read paths must ask
30 * this before rendering or exporting a record, otherwise they leak the title, solution
31 * ID, author and update date of unpublished FAQs.
32 */
33final readonly class RecordVisibility
34{
35    /**
36     * The solution ID Faq::getFaq() stamps on its placeholder for records that do not
37     * exist or that the requester has no permission for.
38     */
39    private const int ACCESS_DENIED_SOLUTION_ID = 42;
40
41    /**
42     * @param array<string, mixed> $faqRecord a record as populated by Faq::getFaq()
43     */
44    public function __construct(
45        private array $faqRecord,
46    ) {
47    }
48
49    public function isVisible(): bool
50    {
51        return $this->isPermitted() && $this->isActivated() && $this->isWithinPublicationWindow();
52    }
53
54    /**
55     * An empty record means getFaq() was never called; treat that as denied so a missing
56     * load cannot be mistaken for a visible record.
57     */
58    private function isPermitted(): bool
59    {
60        if ($this->faqRecord === []) {
61            return false;
62        }
63
64        $solutionId = (int) ($this->faqRecord['solution_id'] ?? self::ACCESS_DENIED_SOLUTION_ID);
65
66        return self::ACCESS_DENIED_SOLUTION_ID !== $solutionId;
67    }
68
69    private function isActivated(): bool
70    {
71        return 'yes' === ($this->faqRecord['active'] ?? 'no');
72    }
73
74    private function isWithinPublicationWindow(): bool
75    {
76        $now = date(format: 'YmdHis');
77        $dateStart = (string) ($this->faqRecord['dateStart'] ?? '');
78        $dateEnd = (string) ($this->faqRecord['dateEnd'] ?? '');
79
80        $hasStarted = $dateStart === '' || $now >= $dateStart;
81        $hasNotEnded = $dateEnd === '' || $now <= $dateEnd;
82
83        return $hasStarted && $hasNotEnded;
84    }
85}