Passed
Push — develop ( 5b2a70...a929c1 )
by Andrew
04:48
created

Statistics   A

Complexity

Total Complexity 28

Size/Duplication

Total Lines 302
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 28
eloc 152
dl 0
loc 302
rs 10
c 0
b 0
f 0

7 Methods

Rating   Name   Duplication   Size   Complexity  
B incrementStatistics() 0 63 8
A clearStatistics() 0 13 2
A trimStatistics() 0 58 5
A rateLimited() 0 12 3
A saveStatistics() 0 44 5
A getAllStatistics() 0 16 2
A getRecentStatistics() 0 26 3
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/
9
 * @copyright Copyright (c) 2018 nystudio107
10
 */
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 */
28
29
/**
30
 * @author    nystudio107
31
 * @package   Retour
32
 * @since     3.0.0
33
 */
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
    /**
45
     * @var null|array
46
     */
47
    protected $cachedStatistics;
48
49
    // Public Methods
50
    // =========================================================================
51
52
    /**
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
    /**
74
     * @param int  $days The number of days to get
75
     * @param bool $handled
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
    /**
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
     * Increment the retour_stats record
127
     *
128
     * @param string             $url The 404 url
129
     * @param bool               $handled
130
     */
131
    public function incrementStatistics(string $url, $handled = false)
132
    {
133
        $referrer = $remoteIp = null;
134
        $request = Craft::$app->getRequest();
135
        $siteId = Craft::$app->getSites()->currentSite->id;
136
        if (!$request->isConsoleRequest) {
137
            $referrer = $request->getReferrer();
138
            if (Retour::$settings->recordRemoteIp) {
139
                $remoteIp = $request->getUserIP();
140
            }
141
            $userAgent = $request->getUserAgent();
142
            if (Retour::$currentException !== null) {
143
                $exceptionMessage = Retour::$currentException->getMessage();
144
                $exceptionFilePath = Retour::$currentException->getFile();
145
                $exceptionFileLine = Retour::$currentException->getLine();
146
            }
147
        }
148
        $referrer = $referrer ?? '';
149
        $remoteIp = $remoteIp ?? '';
150
        $userAgent = $userAgent ?? '';
151
        $exceptionMessage = $exceptionMessage ?? '';
152
        $exceptionFilePath = $exceptionFilePath ?? '';
153
        $exceptionFileLine = $exceptionFileLine ?? 0;
154
        // Strip the query string if `stripQueryStringFromStats` is set
155
        if (Retour::$settings->stripQueryStringFromStats) {
156
            $url = UrlHelper::stripQueryString($url);
157
            $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

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