Lines 95.19% 99 / 104
Methods 85.71% 12 / 14
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 getInstance 100.00% 3 / 3 100.00% 1 / 1 2
 log 100.00% 14 / 14 100.00% 1 / 1 6
 initializeSessionId 100.00% 6 / 6 100.00% 1 / 1 2
 getCookieId 100.00% 1 / 1 100.00% 1 / 1 1
 countBots 100.00% 6 / 6 100.00% 1 / 1 3
 getRemoteAddress 100.00% 6 / 6 100.00% 1 / 1 3
 isBanned 100.00% 2 / 2 100.00% 1 / 1 1
 handleSession 80.95% 17 / 21 0.00% 0 / 1 4.11
 writeTrackingData 97.22% 35 / 36 0.00% 0 / 1 3
 getBotIgnoreList 100.00% 1 / 1 100.00% 1 / 1 1
 getRequestHeaders 100.00% 1 / 1 100.00% 1 / 1 1
 createNetwork 100.00% 5 / 5 100.00% 1 / 1 3
 getTrackingDirectory 100.00% 1 / 1 100.00% 1 / 1 1
39class Tracking
40{
41    private static ?Tracking $tracking = null;
42
43    private ?int $currentSessionId = null;
44
45    private function __construct(
46        private readonly Configuration $configuration,
47        private readonly Request $request,
48        private readonly UserSession $userSession,
49        private readonly ?Closure $networkFactory = null,
50        private readonly ?string $trackingDirectory = null,
51    ) {
52    }
53
54    public static function getInstance(
55        Configuration $configuration,
56        Request $request,
57        UserSession $userSession,
58        ?Closure $networkFactory = null,
59        ?string $trackingDirectory = null,
60    ): Tracking {
61        if (!self::$tracking instanceof Tracking) {
62            self::$tracking = new self($configuration, $request, $userSession, $networkFactory, $trackingDirectory);
63        }
64
65        return self::$tracking;
66    }
67
68    /**
69     * @throws Exception
70     */
71    public function log(string $action, int|string|null $data = null): bool
72    {
73        if (!$this->configuration->get(item: 'main.enableUserTracking')) {
74            return false;
75        }
76
77        $this->initializeSessionId();
78        $cookieId = $this->getCookieId();
79
80        if (!is_null($cookieId)) {
81            $this->userSession->setCurrentSessionId($cookieId);
82        }
83
84        if ($action === SessionActionType::OLD_SESSION->value) {
85            $this->userSession->setCurrentSessionId(0);
86        }
87
88        $bots = $this->countBots();
89        $remoteAddress = $this->getRemoteAddress();
90        $banned = $this->isBanned($remoteAddress);
91
92        if (0 === $bots && false === $banned) {
93            $this->handleSession($cookieId, $remoteAddress, $action, $data);
94        }
95
96        return true;
97    }
98
99    private function initializeSessionId(): void
100    {
101        $sessionId = Filter::filterVar(
102            $this->request->query->get(UserSession::KEY_NAME_SESSION_ID),
103            FILTER_VALIDATE_INT,
104        );
105
106        if ($sessionId !== null) {
107            $this->currentSessionId = $sessionId;
108        }
109    }
110
111    private function getCookieId(): ?int
112    {
113        return Filter::filterVar($this->request->query->get(UserSession::COOKIE_NAME_SESSION_ID), FILTER_VALIDATE_INT);
114    }
115
116    private function countBots(): int
117    {
118        $bots = 0;
119        foreach ($this->getBotIgnoreList() as $bot) {
120            if (!Strings::strstr($this->getRequestHeaders()->get('user-agent') ?? '1', $bot)) {
121                continue;
122            }
123
124            ++$bots;
125        }
126
127        return $bots;
128    }
129
130    public function getRemoteAddress(): string
131    {
132        $remoteAddress = $this->request->getClientIp();
133        $localAddresses = ['127.0.0.1', '::1'];
134
135        if (
136            in_array($remoteAddress, $localAddresses, strict: true)
137            && $this->getRequestHeaders()->has('X-Forwarded-For')
138        ) {
139            $remoteAddress = $this->getRequestHeaders()->get('X-Forwarded-For');
140        }
141
142        return preg_replace(pattern: '([^0-9a-z:.]+)i', replacement: '', subject: (string) $remoteAddress) ?? '';
143    }
144
145    private function isBanned(string $remoteAddress): bool
146    {
147        $network = $this->createNetwork();
148        return $network->isBanned(IpUtils::anonymize($remoteAddress));
149    }
150
151    /**
152     * @throws Exception
153     */
154    private function handleSession(?int $cookieId, string $remoteAddress, string $action, int|string|null $data): void
155    {
156        if ($this->currentSessionId === null) {
157            $this->currentSessionId = $this->configuration->getDb()->nextId(
158                Database::getTablePrefix() . 'faqsessions',
159                'sid',
160            );
161            $this->userSession->setCurrentSessionId($this->currentSessionId);
162
163            if (!is_null($cookieId) && !$cookieId !== $this->userSession->getCurrentSessionId()) {
164                $this->userSession->setCookie(
165                    UserSession::COOKIE_NAME_SESSION_ID,
166                    $this->userSession->getCurrentSessionId(),
167                );
168            }
169
170            $query = sprintf(
171                "INSERT INTO %sfaqsessions (sid, user_id, ip, time) VALUES (%d, %d, '%s', %d)",
172                Database::getTablePrefix(),
173                $this->userSession->getCurrentSessionId(),
174                CurrentUser::getCurrentUser($this->configuration)->getUserId(),
175                $remoteAddress,
176                (int) $this->request->server->get('REQUEST_TIME'),
177            );
178
179            $this->configuration->getDb()->query($query);
180        }
181
182        $this->writeTrackingData($action, $data, $remoteAddress);
183    }
184
185    private function writeTrackingData(string $action, int|string|null $data, string $remoteAddress): void
186    {
187        $redactor = new TrackingDataRedactor();
188
189        $data =
190            (string) $this->userSession->getCurrentSessionId()
191            . ';'
192            . str_replace(search: ';', replace: ',', subject: $action)
193            . ';'
194            . (string) $data
195            . ';'
196            . $remoteAddress
197            . ';'
198            . str_replace(
199                search: ';',
200                replace: ',',
201                subject: $redactor->redactQueryString((string) ($this->request->server->get('QUERY_STRING') ?? '')),
202            )
203            . ';'
204            . str_replace(
205                search: ';',
206                replace: ',',
207                subject: $redactor->redactUrl((string) ($this->request->server->get('HTTP_REFERER') ?? '')),
208            )
209            . ';'
210            . str_replace(
211                search: ';',
212                replace: ',',
213                subject: urldecode((string) $this->request->server->get('HTTP_USER_AGENT')),
214            )
215            . ';'
216            . (int) $this->request->server->get('REQUEST_TIME')
217            . ";\n";
218
219        $file = $this->getTrackingDirectory() . '/tracking' . date(format: 'dmY');
220
221        if (!is_file($file)) {
222            touch($file);
223        }
224
225        if (!is_writable($file)) {
226            $this->configuration->getLogger()->error('Cannot write to ' . $file);
227        }
228
229        file_put_contents($file, $data, FILE_APPEND | LOCK_EX);
230    }
231
232    /**
233     * Returns the botIgnoreList as an array.
234     * @return array<string>
235     */
236    private function getBotIgnoreList(): array
237    {
238        return explode(',', (string) $this->configuration->get(item: 'main.botIgnoreList'));
239    }
240
241    private function getRequestHeaders(): HeaderBag
242    {
243        return $this->request->headers;
244    }
245
246    private function createNetwork(): Network
247    {
248        if ($this->networkFactory instanceof Closure) {
249            $network = ($this->networkFactory)();
250            if ($network instanceof Network) {
251                return $network;
252            }
253        }
254
255        return new Network($this->configuration);
256    }
257
258    private function getTrackingDirectory(): string
259    {
260        return $this->trackingDirectory ?? (string) PMF_ROOT_DIR . '/content/core/data';
261    }
262}