Passed
Push — v3 ( 2b006a...43f239 )
by Andrew
33:34 queued 16:32
created

src/services/Statistics.php (47 issues)

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

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