Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.52% covered (success)
95.52%
128 / 134
81.82% covered (success)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserSession
95.52% covered (success)
95.52%
128 / 134
81.82% covered (success)
81.82%
9 / 11
36
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
 getCurrentSessionId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCurrentSessionId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCurrentUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 checkSessionId
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 getBotIgnoreList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 userTracking
94.12% covered (success)
94.12%
80 / 85
0.00% covered (danger)
0.00%
0 / 1
20.08
 setCookie
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 getRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createNetwork
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getTrackingDirectory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The main Session 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 * @copyright 2007-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2007-03-31
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\User;
21
22use Closure;
23use phpMyFAQ\Configuration;
24use phpMyFAQ\Database;
25use phpMyFAQ\Enums\SessionActionType;
26use phpMyFAQ\Filter;
27use phpMyFAQ\Network;
28use phpMyFAQ\Strings;
29use Symfony\Component\HttpFoundation\IpUtils;
30use Symfony\Component\HttpFoundation\Request;
31
32/**
33 * Class Session
34 *
35 * @package phpMyFAQ
36 */
37class UserSession
38{
39    /** @var string Name of the "remember me" cookie */
40    final public const string COOKIE_NAME_REMEMBER_ME = 'pmf-remember-me';
41
42    /** @var string Name of the session cookie */
43    final public const string COOKIE_NAME_SESSION_ID = 'pmf-sid';
44
45    /** @var string Name of the session GET parameter */
46    final public const string KEY_NAME_SESSION_ID = 'sid';
47
48    private ?int $currentSessionId = null;
49
50    private ?CurrentUser $currentUser = null;
51
52    public function __construct(
53        private readonly Configuration $configuration,
54        private readonly ?Request $request = null,
55        private readonly ?Closure $networkFactory = null,
56        private readonly ?Closure $cookieSetter = null,
57        private readonly ?string $trackingDirectory = null,
58    ) {
59    }
60
61    /**
62     * Returns the current session ID.
63     */
64    public function getCurrentSessionId(): ?int
65    {
66        return $this->currentSessionId;
67    }
68
69    /**
70     * Sets the current session ID.
71     */
72    public function setCurrentSessionId(int $currentSessionId): UserSession
73    {
74        $this->currentSessionId = $currentSessionId;
75        return $this;
76    }
77
78    /**
79     * Sets current User object
80     */
81    public function setCurrentUser(CurrentUser $currentUser): UserSession
82    {
83        $this->currentUser = $currentUser;
84        return $this;
85    }
86
87    /**
88     * Checks the Session ID.
89     *
90     * @param int    $sessionIdToCheck Session ID
91     * @param string $ipAddress IP
92     */
93    public function checkSessionId(int $sessionIdToCheck, string $ipAddress): void
94    {
95        $request = $this->getRequest();
96        $query = sprintf(
97            "SELECT sid FROM %sfaqsessions WHERE sid = %d AND ip = '%s' AND time > %d",
98            Database::getTablePrefix(),
99            $sessionIdToCheck,
100            $ipAddress,
101            (int) $request->server->get('REQUEST_TIME') - 86_400,
102        );
103        $result = $this->configuration->getDb()->query($query);
104
105        if ($this->configuration->getDb()->numRows($result) === 0) {
106            $this->userTracking(SessionActionType::OLD_SESSION->value, $sessionIdToCheck);
107            return;
108        }
109
110        // Update global session id
111        $this->setCurrentSessionId($sessionIdToCheck);
112        // Update db tracking
113        $query = sprintf(
114            "UPDATE %sfaqsessions SET time = %d, user_id = %d WHERE sid = %d AND ip = '%s'",
115            Database::getTablePrefix(),
116            (int) $request->server->get('REQUEST_TIME'),
117            $this->currentUser instanceof CurrentUser ? $this->currentUser->getUserId() : 0,
118            $sessionIdToCheck,
119            $ipAddress,
120        );
121        $this->configuration->getDb()->query($query);
122    }
123
124    /**
125     * Returns the botIgnoreList as an array.
126     *
127     * @return array<string>
128     */
129    private function getBotIgnoreList(): array
130    {
131        return explode(',', (string) $this->configuration->get(item: 'main.botIgnoreList'));
132    }
133
134    /**
135     * Tracks the user and log what he did.
136     */
137    public function userTracking(string $action, int|string|null $data = null): void
138    {
139        if (!$this->configuration->get(item: 'main.enableUserTracking')) {
140            return;
141        }
142
143        $request = $this->getRequest();
144        $bots = 0;
145        $banned = false;
146        $this->currentSessionId = Filter::filterVar(
147            $request->query->get(self::KEY_NAME_SESSION_ID),
148            FILTER_VALIDATE_INT,
149        );
150        $cookieId = Filter::filterVar($request->query->get(self::COOKIE_NAME_SESSION_ID), FILTER_VALIDATE_INT);
151
152        if (!is_null($cookieId)) {
153            $this->setCurrentSessionId($cookieId);
154        }
155
156        if ($action === SessionActionType::OLD_SESSION->value) {
157            $this->setCurrentSessionId(0);
158        }
159
160        $userAgent = (string) $request->headers->get('user-agent');
161        foreach ($this->getBotIgnoreList() as $bot) {
162            if (!Strings::strstr($userAgent, $bot)) {
163                continue;
164            }
165
166            ++$bots;
167        }
168
169        // if we're running behind a reverse proxy like nginx/varnish, fix the client IP
170        $remoteAddress = $request->getClientIp();
171        $localAddresses = ['127.0.0.1', '::1'];
172
173        if (in_array($remoteAddress, $localAddresses, strict: true) && $request->headers->has('X-Forwarded-For')) {
174            $remoteAddress = $request->headers->get('X-Forwarded-For');
175        }
176
177        // clean up as well
178        $remoteAddress = preg_replace('([^0-9a-z:.]+)i', replacement: '', subject: (string) $remoteAddress);
179
180        if (
181            !is_string($remoteAddress)
182            || $remoteAddress === ''
183            || filter_var($remoteAddress, FILTER_VALIDATE_IP) === false
184        ) {
185            $remoteAddress = '127.0.0.1';
186        }
187
188        // Anonymize IP address
189        $remoteAddress = IpUtils::anonymize($remoteAddress);
190
191        $network = $this->createNetwork();
192        if ($network->isBanned($remoteAddress)) {
193            $banned = true;
194        }
195
196        if (0 === $bots && false === $banned) {
197            if ($this->currentSessionId === null) {
198                $this->currentSessionId = $this->configuration->getDb()->nextId(
199                    Database::getTablePrefix() . 'faqsessions',
200                    'sid',
201                );
202                // Check: force the session cookie to contains the current $sid
203                if ($cookieId !== null && $cookieId !== $this->getCurrentSessionId()) {
204                    self::setCookie(self::COOKIE_NAME_SESSION_ID, $this->getCurrentSessionId());
205                }
206
207                $query = sprintf(
208                    "INSERT INTO %sfaqsessions (sid, user_id, ip, time) VALUES (%d, %d, '%s', %d)",
209                    Database::getTablePrefix(),
210                    $this->getCurrentSessionId(),
211                    $this->currentUser instanceof CurrentUser ? $this->currentUser->getUserId() : 0,
212                    $remoteAddress,
213                    (int) $request->server->get('REQUEST_TIME'),
214                );
215
216                $this->configuration->getDb()->query($query);
217            }
218
219            $redactor = new TrackingDataRedactor();
220
221            $data =
222                (string) $this->getCurrentSessionId()
223                . ';'
224                . str_replace(search: ';', replace: ',', subject: $action)
225                . ';'
226                . (string) $data
227                . ';'
228                . $remoteAddress
229                . ';'
230                . str_replace(
231                    search: ';',
232                    replace: ',',
233                    subject: $redactor->redactQueryString((string) ($request->server->get('QUERY_STRING') ?? '')),
234                )
235                . ';'
236                . str_replace(
237                    search: ';',
238                    replace: ',',
239                    subject: $redactor->redactUrl((string) ($request->server->get('HTTP_REFERER') ?? '')),
240                )
241                . ';'
242                . str_replace(
243                    search: ';',
244                    replace: ',',
245                    subject: urldecode(string: (string) $request->server->get('HTTP_USER_AGENT')),
246                )
247                . ';'
248                . (int) $request->server->get('REQUEST_TIME')
249                . ";\n";
250
251            $file = $this->getTrackingDirectory() . '/tracking' . date(format: 'dmY');
252
253            if (!is_file($file)) {
254                touch($file);
255            }
256
257            if (!is_writable($file)) {
258                $this->configuration->getLogger()->error('Cannot write to ' . $file);
259            }
260
261            file_put_contents($file, $data, FILE_APPEND | LOCK_EX);
262        }
263    }
264
265    /**
266     * Store the Session ID into a persistent cookie expiring
267     * 3600 seconds after the page request.
268     *
269     * @param string          $name Cookie name
270     * @param int|string|null $sessionId Session ID
271     * @param int             $timeout Cookie timeout
272     */
273    public function setCookie(string $name, int|string|null $sessionId, int $timeout = 3600, bool $strict = true): void
274    {
275        $request = $this->getRequest();
276
277        $options = [
278            'expires' => (int) $request->server->get('REQUEST_TIME') + $timeout,
279            'path' => dirname((string) $request->server->get('SCRIPT_NAME')),
280            'domain' => parse_url($this->configuration->getDefaultUrl(), PHP_URL_HOST),
281            'secure' => $request->isSecure(),
282            'httponly' => true,
283            'samesite' => $strict ? 'strict' : '',
284        ];
285
286        if ($this->cookieSetter instanceof Closure) {
287            ($this->cookieSetter)($name, (string) $sessionId ?? '', $options);
288            return;
289        }
290
291        setcookie($name, (string) $sessionId ?? '', $options);
292    }
293
294    private function getRequest(): Request
295    {
296        return $this->request ?? Request::createFromGlobals();
297    }
298
299    private function createNetwork(): Network
300    {
301        if ($this->networkFactory instanceof Closure) {
302            $network = ($this->networkFactory)($this->configuration);
303            if ($network instanceof Network) {
304                return $network;
305            }
306        }
307
308        return new Network($this->configuration);
309    }
310
311    private function getTrackingDirectory(): string
312    {
313        return $this->trackingDirectory ?? (string) PMF_ROOT_DIR . '/content/core/data';
314    }
315}