Statistics   A
last analyzed

Complexity

Total Complexity 32

Size/Duplication

Total Lines 335
Duplicated Lines 0 %

Importance

Changes 3
Bugs 2 Features 1
Metric Value
wmc 32
eloc 165
c 3
b 2
f 1
dl 0
loc 335
rs 9.84

8 Methods

Rating   Name   Duplication   Size   Complexity  
A getAllStatistics() 0 16 2
A clearStatistics() 0 13 2
A getRecentStatistics() 0 26 3
A trimStatistics() 0 58 5
A deleteStatisticById() 0 17 2
A rateLimited() 0 12 3
A saveStatistics() 0 44 5
B incrementStatistics() 0 69 10
1
<?php
2
/**
3
 * Retour plugin for Craft CMS
4
 *
5
 * Retour allows you to intelligently redirect legacy URLs, so that you don't
6
 * lose SEO value when rebuilding & restructuring a website
7
 *
8
 * @link      https://nystudio107.com/
0 ignored issues
show
Coding Style introduced by
The tag in position 1 should be the @copyright tag
Loading history...
9
 * @copyright Copyright (c) 2018 nystudio107
0 ignored issues
show
Coding Style introduced by
@copyright tag must contain a year and the name of the copyright holder
Loading history...
10
 */
0 ignored issues
show
Coding Style introduced by
PHP version not specified
Loading history...
Coding Style introduced by
Missing @category tag in file comment
Loading history...
Coding Style introduced by
Missing @package tag in file comment
Loading history...
Coding Style introduced by
Missing @author tag in file comment
Loading history...
Coding Style introduced by
Missing @license tag in file comment
Loading history...
11
12
namespace nystudio107\retour\services;
13
14
use Craft;
15
use craft\base\Component;
16
use craft\db\Query;
17
use craft\helpers\Db;
18
use craft\helpers\UrlHelper;
19
use DateTime;
20
use nystudio107\retour\helpers\Text as TextHelper;
21
use nystudio107\retour\models\Stats as StatsModel;
22
use nystudio107\retour\Retour;
23
use yii\db\Exception;
24
25
/** @noinspection MissingPropertyAnnotationsInspection */
0 ignored issues
show
Coding Style introduced by
The open comment tag must be the only content on the line
Loading history...
Coding Style introduced by
The close comment tag must be the only content on the line
Loading history...
Coding Style introduced by
Missing short description in doc comment
Loading history...
26
27
/**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
28
 * @author    nystudio107
0 ignored issues
show
Coding Style introduced by
The tag in position 1 should be the @package tag
Loading history...
Coding Style introduced by
Content of the @author tag must be in the form "Display Name <[email protected]>"
Loading history...
Coding Style introduced by
Tag value for @author tag indented incorrectly; expected 2 spaces but found 4
Loading history...
29
 * @package   Retour
0 ignored issues
show
Coding Style introduced by
Tag value for @package tag indented incorrectly; expected 1 spaces but found 3
Loading history...
30
 * @since     3.0.0
0 ignored issues
show
Coding Style introduced by
The tag in position 3 should be the @author tag
Loading history...
Coding Style introduced by
Tag value for @since tag indented incorrectly; expected 3 spaces but found 5
Loading history...
31
 */
0 ignored issues
show
Coding Style introduced by
Missing @category tag in class comment
Loading history...
Coding Style introduced by
Missing @license tag in class comment
Loading history...
Coding Style introduced by
Missing @link tag in class comment
Loading history...
32
class Statistics extends Component
33
{
34
    // Constants
35
    // =========================================================================
36
37
    public const LAST_STATISTICS_TRIM_CACHE_KEY = 'retour-last-statistics-trim';
38
39
    // Protected Properties
40
    // =========================================================================
41
42
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
43
     * @var null|array
44
     */
45
    protected ?array $cachedStatistics;
46
47
    // Public Methods
48
    // =========================================================================
49
50
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
51
     * @return array All of the statistics
52
     */
53
    public function getAllStatistics(): array
54
    {
55
        // Cache it in our class; no need to fetch it more than once
56
        if ($this->cachedStatistics !== null) {
57
            return $this->cachedStatistics;
58
        }
59
        // Query the db table
60
        $stats = (new Query())
61
            ->from(['{{%retour_stats}}'])
62
            ->orderBy('hitCount DESC')
63
            ->limit(Retour::$settings->statsDisplayLimit)
64
            ->all();
65
        // Cache for future accesses
66
        $this->cachedStatistics = $stats;
67
68
        return $stats;
69
    }
70
71
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
72
     * @param int $days The number of days to get
0 ignored issues
show
Coding Style introduced by
Expected 2 spaces after parameter type; 1 found
Loading history...
Coding Style introduced by
Expected 4 spaces after parameter name; 1 found
Loading history...
73
     * @param bool $handled
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
74
     *
75
     * @return array Recent statistics
76
     */
77
    public function getRecentStatistics(int $days = 1, bool $handled = false): array
78
    {
79
        // Ensure is an int
80
        $handledInt = (int)$handled;
81
        $stats = [];
82
        $db = Craft::$app->getDb();
83
        if ($db->getIsMysql()) {
84
            // Query the db table
85
            $stats = (new Query())
86
                ->from(['{{%retour_stats}}'])
87
                ->where("hitLastTime >= ( CURDATE() - INTERVAL '{$days}' DAY )")
88
                ->andWhere("handledByRetour = {$handledInt}")
89
                ->orderBy('hitLastTime DESC')
90
                ->all();
91
        }
92
        if ($db->getIsPgsql()) {
93
            // Query the db table
94
            $stats = (new Query())
95
                ->from(['{{%retour_stats}}'])
96
                ->where("\"hitLastTime\" >= ( CURRENT_TIMESTAMP - INTERVAL '{$days} days' )")
97
                ->andWhere(['handledByRetour' => $handledInt])
98
                ->orderBy('hitLastTime DESC')
99
                ->all();
100
        }
101
102
        return $stats;
103
    }
104
105
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
106
     * @return int
107
     */
108
    public function clearStatistics(): int
109
    {
110
        $db = Craft::$app->getDb();
111
        try {
112
            $result = $db->createCommand()
113
                ->truncateTable('{{%retour_stats}}')
114
                ->execute();
115
        } catch (Exception $e) {
116
            $result = -1;
117
            Craft::error($e->getMessage(), __METHOD__);
118
        }
119
120
        return $result;
121
    }
122
123
    /**
124
     * Delete a statistic by id
125
     *
126
     * @param int $id
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
127
     *
128
     * @return int The result
129
     */
130
    public function deleteStatisticById(int $id): int
131
    {
132
        $db = Craft::$app->getDb();
133
        // Delete a row from the db table
134
        try {
135
            $result = $db->createCommand()->delete(
136
                '{{%retour_stats}}',
137
                [
138
                    'id' => $id,
139
                ]
140
            )->execute();
141
        } catch (Exception $e) {
142
            Craft::error($e->getMessage(), __METHOD__);
143
            $result = 0;
144
        }
145
146
        return $result;
147
    }
148
149
    /**
150
     * Increment the retour_stats record
151
     *
152
     * @param string $url The 404 url
0 ignored issues
show
Coding Style introduced by
Expected 5 spaces after parameter name; 1 found
Loading history...
153
     * @param bool $handled
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
Coding Style introduced by
Expected 3 spaces after parameter type; 1 found
Loading history...
154
     * @param null $siteId
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $siteId is correct as it would always require null to be passed?
Loading history...
Coding Style introduced by
Missing parameter comment
Loading history...
Coding Style introduced by
Expected 3 spaces after parameter type; 1 found
Loading history...
155
     */
0 ignored issues
show
Coding Style introduced by
Missing @return tag in function comment
Loading history...
156
    public function incrementStatistics(string $url, bool $handled = false, $siteId = null): void
157
    {
158
        if (Retour::$settings->enableStatistics === false) {
159
            return;
160
        }
161
162
        $referrer = $remoteIp = null;
163
        $request = Craft::$app->getRequest();
164
        if ($siteId === null) {
0 ignored issues
show
introduced by
The condition $siteId === null is always true.
Loading history...
165
            $siteId = Craft::$app->getSites()->currentSite->id;
166
        }
167
        if (!$request->isConsoleRequest) {
168
            $referrer = $request->getReferrer();
169
            if (Retour::$settings->recordRemoteIp) {
170
                $remoteIp = $request->getUserIP();
171
            }
172
            $userAgent = $request->getUserAgent();
173
            if (Retour::$currentException !== null) {
174
                $exceptionMessage = Retour::$currentException->getMessage();
175
                $exceptionFilePath = Retour::$currentException->getFile();
176
                $exceptionFileLine = Retour::$currentException->getLine();
177
            }
178
        }
179
        $referrer = $referrer ?? '';
180
        $remoteIp = $remoteIp ?? '';
181
        $userAgent = $userAgent ?? '';
182
        $exceptionMessage = $exceptionMessage ?? '';
183
        $exceptionFilePath = $exceptionFilePath ?? '';
184
        $exceptionFileLine = $exceptionFileLine ?? 0;
185
        // Strip the query string if `stripQueryStringFromStats` is set
186
        if (Retour::$settings->stripQueryStringFromStats) {
187
            $url = UrlHelper::stripQueryString($url);
188
            $referrer = UrlHelper::stripQueryString($referrer);
0 ignored issues
show
Bug introduced by
It seems like $referrer can also be of type null; however, parameter $url of craft\helpers\UrlHelper::stripQueryString() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

188
            $referrer = UrlHelper::stripQueryString(/** @scrutinizer ignore-type */ $referrer);
Loading history...
189
        }
190
        // Normalize the $url via the validator
191
        $stats = new StatsModel([
0 ignored issues
show
Coding Style introduced by
The opening parenthesis of a multi-line function call should be the last content on the line.
Loading history...
192
            'redirectSrcUrl' => TextHelper::cleanupText($url),
193
        ]);
0 ignored issues
show
Coding Style introduced by
For multi-line function calls, the closing parenthesis should be on a new line.

If a function call spawns multiple lines, the coding standard suggests to move the closing parenthesis to a new line:

someFunctionCall(
    $firstArgument,
    $secondArgument,
    $thirdArgument
); // Closing parenthesis on a new line.
Loading history...
194
        $stats->validate();
195
        // Find any existing retour_stats record
196
        $statsConfig = (new Query())
197
            ->from(['{{%retour_stats}}'])
198
            ->where(['redirectSrcUrl' => $stats->redirectSrcUrl])
199
            ->one();
200
        // If no record is found, initialize some values
201
        if ($statsConfig === null) {
202
            $stats->id = 0;
203
            $stats->hitCount = 0;
204
        } else {
205
            $stats->id = $statsConfig['id'];
206
            $stats->hitCount = $statsConfig['hitCount'];
207
        }
208
        // Merge in the updated info
209
        $stats->siteId = $siteId;
210
        $stats->referrerUrl = $referrer;
211
        $stats->remoteIp = $remoteIp;
212
        $stats->userAgent = $userAgent;
213
        $stats->exceptionMessage = $exceptionMessage;
214
        $stats->exceptionFilePath = $exceptionFilePath;
215
        $stats->exceptionFileLine = (int)$exceptionFileLine;
216
        $stats->hitLastTime = Db::prepareDateForDb(new DateTime());
217
        $stats->handledByRetour = (int)$handled;
218
        $stats->hitCount++;
219
        $statsConfig = $stats->getAttributes();
220
        // Record the updated statistics
221
        $this->saveStatistics($statsConfig);
222
        // After incrementing a statistic, trim the retour_stats db table
223
        if (Retour::$settings->automaticallyTrimStatistics && !$this->rateLimited()) {
224
            $this->trimStatistics();
225
        }
226
    }
227
228
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
229
     * @param array $statsConfig
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
230
     */
0 ignored issues
show
Coding Style introduced by
Missing @return tag in function comment
Loading history...
231
    public function saveStatistics(array $statsConfig): void
232
    {
233
        // Validate the model before saving it to the db
234
        $stats = new StatsModel($statsConfig);
235
        if ($stats->validate() === false) {
236
            Craft::error(
237
                Craft::t(
238
                    'retour',
239
                    'Error validating statistics {id}: {errors}',
240
                    ['id' => $stats->id, 'errors' => print_r($stats->getErrors(), true)]
241
                ),
242
                __METHOD__
243
            );
244
245
            return;
246
        }
247
        // Get the validated model attributes and save them to the db
248
        $statsConfig = $stats->getAttributes();
249
        $db = Craft::$app->getDb();
250
        if ($statsConfig['id'] !== 0) {
251
            // Update the existing record
252
            try {
253
                $result = $db->createCommand()->update(
0 ignored issues
show
Unused Code introduced by
The assignment to $result is dead and can be removed.
Loading history...
254
                    '{{%retour_stats}}',
255
                    $statsConfig,
256
                    [
257
                        'id' => $statsConfig['id'],
258
                    ]
259
                )->execute();
260
            } catch (Exception $e) {
261
                // We don't log this error on purpose, because it's just a stats
262
                // update, and deadlock errors can potentially occur
263
                // Craft::error($e->getMessage(), __METHOD__);
264
            }
265
        } else {
266
            unset($statsConfig['id']);
267
            // Create a new record
268
            try {
269
                $db->createCommand()->insert(
270
                    '{{%retour_stats}}',
271
                    $statsConfig
272
                )->execute();
273
            } catch (Exception $e) {
274
                Craft::error($e->getMessage(), __METHOD__);
275
            }
276
        }
277
    }
278
279
    /**
280
     * Trim the retour_stats db table based on the statsStoredLimit config.php
281
     * setting
282
     *
283
     * @param int|null $limit
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
284
     *
285
     * @return int
286
     */
287
    public function trimStatistics(int $limit = null): int
288
    {
289
        $affectedRows = 0;
290
        $db = Craft::$app->getDb();
291
        $quotedTable = $db->quoteTableName('{{%retour_stats}}');
292
        $limit = $limit ?? Retour::$settings->statsStoredLimit;
293
294
        if ($limit !== null) {
0 ignored issues
show
introduced by
The condition $limit !== null is always true.
Loading history...
295
            //  https://stackoverflow.com/questions/578867/sql-query-delete-all-records-from-the-table-except-latest-n
296
            try {
297
                if ($db->getIsMysql()) {
298
                    // Handle MySQL
299
                    $affectedRows = $db->createCommand(/** @lang mysql */
0 ignored issues
show
Coding Style introduced by
The open comment tag must be the only content on the line
Loading history...
Coding Style introduced by
Missing short description in doc comment
Loading history...
Coding Style introduced by
The close comment tag must be the only content on the line
Loading history...
300
                        "
301
                        DELETE FROM {$quotedTable}
302
                        WHERE id NOT IN (
303
                          SELECT id
304
                          FROM (
305
                            SELECT id
306
                            FROM {$quotedTable}
307
                            ORDER BY hitLastTime DESC
308
                            LIMIT {$limit}
309
                          ) foo
310
                        )
311
                        "
312
                    )->execute();
313
                }
314
                if ($db->getIsPgsql()) {
315
                    // Handle Postgres
316
                    $affectedRows = $db->createCommand(/** @lang mysql */
0 ignored issues
show
Coding Style introduced by
The open comment tag must be the only content on the line
Loading history...
Coding Style introduced by
Missing short description in doc comment
Loading history...
Coding Style introduced by
The close comment tag must be the only content on the line
Loading history...
317
                        "
318
                        DELETE FROM {$quotedTable}
319
                        WHERE id NOT IN (
320
                          SELECT id
321
                          FROM (
322
                            SELECT id
323
                            FROM {$quotedTable}
324
                            ORDER BY \"hitLastTime\" DESC
325
                            LIMIT {$limit}
326
                          ) foo
327
                        )
328
                        "
329
                    )->execute();
330
                }
331
            } catch (Exception $e) {
332
                Craft::error($e->getMessage(), __METHOD__);
333
            }
334
            Craft::info(
335
                Craft::t(
336
                    'retour',
337
                    'Trimmed {rows} from retour_stats table',
338
                    ['rows' => $affectedRows]
339
                ),
340
                __METHOD__
341
            );
342
        }
343
344
        return $affectedRows;
345
    }
346
347
    // Protected Methods
348
    // =========================================================================
349
350
    /**
351
     * Don't trim more than a given interval, so that performance is not affected
352
     *
353
     * @return bool
354
     */
355
    protected function rateLimited(): bool
356
    {
357
        $limited = false;
358
        $now = round(microtime(true) * 1000);
359
        $cache = Craft::$app->getCache();
360
        $then = $cache->get(self::LAST_STATISTICS_TRIM_CACHE_KEY);
361
        if (($then !== false) && ($now - (int)$then < Retour::$settings->statisticsRateLimitMs)) {
362
            $limited = true;
363
        }
364
        $cache->set(self::LAST_STATISTICS_TRIM_CACHE_KEY, $now, 0);
365
366
        return $limited;
367
    }
368
}
369