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
![]() |
|||||
9 | * @copyright Copyright (c) 2018 nystudio107 |
||||
0 ignored issues
–
show
|
|||||
10 | */ |
||||
0 ignored issues
–
show
|
|||||
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
|
|||||
26 | |||||
27 | /** |
||||
0 ignored issues
–
show
|
|||||
28 | * @author nystudio107 |
||||
0 ignored issues
–
show
Content of the @author tag must be in the form "Display Name <[email protected]>"
![]() |
|||||
29 | * @package Retour |
||||
0 ignored issues
–
show
|
|||||
30 | * @since 3.0.0 |
||||
0 ignored issues
–
show
|
|||||
31 | */ |
||||
0 ignored issues
–
show
|
|||||
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
|
|||||
43 | * @var null|array |
||||
44 | */ |
||||
45 | protected ?array $cachedStatistics; |
||||
46 | |||||
47 | // Public Methods |
||||
48 | // ========================================================================= |
||||
49 | |||||
50 | /** |
||||
0 ignored issues
–
show
|
|||||
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
|
|||||
72 | * @param int $days The number of days to get |
||||
0 ignored issues
–
show
|
|||||
73 | * @param bool $handled |
||||
0 ignored issues
–
show
|
|||||
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
|
|||||
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
|
|||||
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
|
|||||
153 | * @param bool $handled |
||||
0 ignored issues
–
show
|
|||||
154 | * @param null $siteId |
||||
0 ignored issues
–
show
|
|||||
155 | */ |
||||
0 ignored issues
–
show
|
|||||
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
|
|||||
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
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
![]() |
|||||
189 | } |
||||
190 | // Normalize the $url via the validator |
||||
191 | $stats = new StatsModel([ |
||||
0 ignored issues
–
show
|
|||||
192 | 'redirectSrcUrl' => TextHelper::cleanupText($url), |
||||
193 | ]); |
||||
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.
![]() |
|||||
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
|
|||||
229 | * @param array $statsConfig |
||||
0 ignored issues
–
show
|
|||||
230 | */ |
||||
0 ignored issues
–
show
|
|||||
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
|
|||||
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
|
|||||
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
|
|||||
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
|
|||||
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
|
|||||
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 |