Issues (1698)

sources/ElkArte/AdminController/Maintenance.php (2 issues)

1
<?php
2
3
/**
4
 * Forum maintenance. Important stuff.
5
 *
6
 * @package   ElkArte Forum
7
 * @copyright ElkArte Forum contributors
8
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
9
 *
10
 * This file contains code covered by:
11
 * copyright: 2011 Simple Machines (http://www.simplemachines.org)
12
 *
13
 * @version 2.0 Beta 1
14
 *
15
 */
16
17
namespace ElkArte\AdminController;
18
19
use ElkArte\AbstractController;
20
use ElkArte\Action;
21
use ElkArte\Cache\Cache;
22
use ElkArte\Debug;
23
use ElkArte\EventManager;
24
use ElkArte\Exceptions\Exception;
25
use ElkArte\Helper\DataValidator;
26
use ElkArte\Http\FtpConnection;
27
use ElkArte\Languages\Txt;
28
use ElkArte\User;
29
30
/**
31
 * Entry point class for all maintenance, routine, members, database,
32
 * attachments, topics, and hooks
33
 *
34
 * @package Maintenance
35
 */
36
class Maintenance extends AbstractController
37
{
38
	/** @var int Maximum topic counter */
39
	public int $max_topics;
40
41
	/** @var int How many actions to take for a maintenance action */
42
	public int $increment;
43
44
	/** @var int Total steps for a given maintenance action */
45
	public int $total_steps;
46
47
	/** @var int reStart pointer for paused maintenance actions */
48
	public int $start;
49
50
	/** @var int Loop counter for paused maintenance actions */
51
	public int $step;
52
53
	/**
54
	 * Main dispatcher, the maintenance access point.
55
	 *
56
	 * What it does:
57
	 *
58
	 * - This, as usual, checks permissions, loads language files,
59
	 * and forwards to the actual workers.
60
	 *
61
	 * @see AbstractController::action_index
62
	 */
63
	public function action_index()
64
	{
65
		global $txt, $context;
66
67
		// You absolutely must be an admin by here!
68
		isAllowedTo('admin_forum');
69
70
		// Need something to talk about?
71
		Txt::load('Maintenance');
72
		theme()->getTemplates()->load('Maintenance');
73
74
		// This uses admin tabs - as it should!
75
		// Create the tabs
76
		$context[$context['admin_menu_name']]['object']->prepareTabData([
77
				'title' => 'maintain_title',
78
				'description' => 'maintain_info',
79
				'class' => 'i-cog']
80
		);
81
82
		// So many things you can do - but frankly, I won't let you - just these!
83
		$subActions = [
84
			'routine' => [$this, 'action_routine',
85
				'activities' => [
86
					'repair' => 'action_repair_display',
87
					'recount' => 'action_recount_display',
88
					'logs' => 'action_logs_display',
89
					'cleancache' => 'action_cleancache_display',
90
				],
91
			],
92
			'database' => [$this, 'action_database',
93
				'activities' => [
94
					'optimize' => 'action_optimize_display',
95
					'backup' => 'action_backup_display',
96
					'convertmsgbody' => 'action_convertmsgbody_display',
97
				],
98
			],
99
			'members' => [$this, 'action_members',
100
				'activities' => [
101
					'reattribute' => 'action_reattribute_display',
102
					'purgeinactive' => 'action_purgeinactive_display',
103
					'recountposts' => 'action_recountposts_display',
104
				],
105
			],
106
			'topics' => [$this, 'action_topics',
107
				'activities' => [
108
					'massmove' => 'action_massmove_display',
109
					'pruneold' => 'action_pruneold_display',
110
				],
111
			],
112
			'hooks' => [$this, 'action_hooks'],
113
			'attachments' => ['controller' => ManageAttachments::class, 'function' => 'action_maintenance'],
114
		];
115
116
		// Set up the action handler
117
		$action = new Action('manage_maintenance');
118
119
		// Yep, sub-action time and call integrate_sa_manage_maintenance as well
120
		$subAction = $action->initialize($subActions, 'routine');
121
122
		// Doing something special, does it exist?
123
		$activity = $this->_req->getQuery('activity', 'trim|strval', '');
124
125
		// Set a few things.
126
		$context[$context['admin_menu_name']]['current_subsection'] = $subAction;
127
		$context['page_title'] = $txt['maintain_title'];
128
		$context['sub_action'] = $subAction;
129
130
		// Finally, fall through to what we are doing.
131
		$action->dispatch($subAction);
132
133
		// Any special activity defined, then go to it.
134
		if (isset($subActions[$subAction]['activities'][$activity]))
135
		{
136
			if (is_string($subActions[$subAction]['activities'][$activity]) && method_exists($this, $subActions[$subAction]['activities'][$activity]))
137
			{
138
				$this->{$subActions[$subAction]['activities'][$activity]}();
139
			}
140
			elseif (is_string($subActions[$subAction]['activities'][$activity]))
141
			{
142
				$subActions[$subAction]['activities'][$activity]();
143
			}
144
			elseif (is_array($subActions[$subAction]['activities'][$activity]))
145
			{
146
				$activity_obj = new $subActions[$subAction]['activities'][$activity]['class']();
147
				$activity_obj->{$subActions[$subAction]['activities'][$activity]['method']}();
148
			}
149
			else
150
			{
151
				$subActions[$subAction]['activities'][$activity]();
152
			}
153
		}
154
155
		// Create a maintenance token.  Kinda hard to do it any other way.
156
		createToken('admin-maint');
157
	}
158
159
	/**
160
	 * Supporting function for the routine maintenance area.
161
	 *
162
	 * @event integrate_routine_maintenance, passed $context['routine_actions'] array to allow
163
	 * addons to add more options
164
	 * @uses Template Maintenance, sub template maintain_routine
165
	 */
166
	public function action_routine(): void
167
	{
168
		global $context, $txt;
169
170
		if ($this->_req->compareQuery('done', 'recount', 'trim|strval'))
171
		{
172
			$context['maintenance_finished'] = $txt['maintain_recount'];
173
		}
174
175
		// set up the sub-template
176
		$context['sub_template'] = 'maintain_routine';
177
		$context['routine_actions'] = [
178
			'repair' => [
179
				'url' => getUrl('admin', ['action' => 'admin', 'area' => 'repairboards']),
180
				'title' => $txt['maintain_errors'],
181
				'description' => $txt['maintain_errors_info'],
182
				'submit' => $txt['maintain_run_now'],
183
				'hidden' => [
184
					'session_var' => 'session_id',
185
					'admin-maint_token_var' => 'admin-maint_token',
186
				]
187
			],
188
			'recount' => [
189
				'url' => getUrl('admin', ['action' => 'admin', 'area' => 'maintain', 'sa' => 'routine', 'activity' => 'recount']),
190
				'title' => $txt['maintain_recount'],
191
				'description' => $txt['maintain_recount_info'],
192
				'submit' => $txt['maintain_run_now'],
193
				'hidden' => [
194
					'session_var' => 'session_id',
195
					'admin-maint_token_var' => 'admin-maint_token',
196
				]
197
			],
198
			'logs' => [
199
				'url' => getUrl('admin', ['action' => 'admin', 'area' => 'maintain', 'sa' => 'routine', 'activity' => 'logs']),
200
				'title' => $txt['maintain_logs'],
201
				'description' => $txt['maintain_logs_info'],
202
				'submit' => $txt['maintain_run_now'],
203
				'hidden' => [
204
					'session_var' => 'session_id',
205
					'admin-maint_token_var' => 'admin-maint_token',
206
				]
207
			],
208
			'cleancache' => [
209
				'url' => getUrl('admin', ['action' => 'admin', 'area' => 'maintain', 'sa' => 'routine', 'activity' => 'cleancache']),
210
				'title' => $txt['maintain_cache'],
211
				'description' => $txt['maintain_cache_info'],
212
				'submit' => $txt['maintain_run_now'],
213
				'hidden' => [
214
					'session_var' => 'session_id',
215
					'admin-maint_token_var' => 'admin-maint_token',
216
				]
217
			],
218
		];
219
220
		call_integration_hook('integrate_routine_maintenance', [&$context['routine_actions']]);
221
	}
222
223
	/**
224
	 * Supporting function for the members maintenance area.
225
	 */
226
	public function action_members(): void
227
	{
228
		global $context, $txt;
229
230
		require_once(SUBSDIR . '/Membergroups.subs.php');
231
232
		// Get all membergroups - for deleting members and the like.
233
		$context['membergroups'] = getBasicMembergroupData(['all']);
234
235
		// Show that we completed this action
236
		if ($this->_req->compareQuery('done', 'recountposts', 'trim|strval'))
237
		{
238
			$context['maintenance_finished'] = [
239
				'errors' => [sprintf($txt['maintain_done'], $txt['maintain_recountposts'])],
240
			];
241
		}
242
243
		loadJavascriptFile('suggest.js', ['defer' => true]);
244
245
		// Set up the sub-template
246
		$context['sub_template'] = 'maintain_members';
247
	}
248
249
	/**
250
	 * Supporting function for the topics maintenance area.
251
	 *
252
	 * @event integrate_topics_maintenance, passed $context['topics_actions'] to allow addons
253
	 * to add additonal topic maintance functions
254
	 * @uses GenericBoards template, sub template maintain_topics
255
	 */
256
	public function action_topics(): void
257
	{
258
		global $context, $txt;
259
260
		require_once(SUBSDIR . '/Boards.subs.php');
261
262
		// Let's load up the boards in case they are useful.
263
		$context += getBoardList(['not_redirection' => true]);
264
265
		// Include a list of boards per category for easy toggling.
266
		foreach ($context['categories'] as $cat => &$category)
267
		{
268
			$context['boards_in_category'][$cat] = count($category['boards']);
269
			$category['child_ids'] = array_keys($category['boards']);
270
		}
271
272
		// @todo Hacky!
273
		$txt['choose_board'] = $txt['maintain_old_all'];
274
		$context['boards_check_all'] = true;
275
		theme()->getTemplates()->load('GenericBoards');
276
277
		$context['topics_actions'] = [
278
			'pruneold' => [
279
				'url' => getUrl('admin', ['action' => 'admin', 'area' => 'maintain', 'sa' => 'topics', 'activity' => 'pruneold']),
280
				'title' => $txt['maintain_old'],
281
				'submit' => $txt['maintain_old_remove'],
282
				'confirm' => $txt['maintain_old_confirm'],
283
				'hidden' => [
284
					'session_var' => 'session_id',
285
					'admin-maint_token_var' => 'admin-maint_token',
286
				]
287
			],
288
			'massmove' => [
289
				'url' => getUrl('admin', ['action' => 'admin', 'area' => 'maintain', 'sa' => 'topics', 'activity' => 'massmove']),
290
				'title' => $txt['move_topics_maintenance'],
291
				'submit' => $txt['move_topics_now'],
292
				'confirm' => $txt['move_topics_confirm'],
293
				'hidden' => [
294
					'session_var' => 'session_id',
295
					'admin-maint_token_var' => 'admin-maint_token',
296
				]
297
			],
298
		];
299
300
		call_integration_hook('integrate_topics_maintenance', [&$context['topics_actions']]);
301
302
		if ($this->_req->compareQuery('done', 'purgeold', 'trim|strval'))
303
		{
304
			$context['maintenance_finished'] = [
305
				'errors' => [sprintf($txt['maintain_done'], $txt['maintain_old'])],
306
			];
307
		}
308
		elseif ($this->_req->compareQuery('done', 'massmove', 'trim|strval'))
309
		{
310
			$context['maintenance_finished'] = [
311
				'errors' => [sprintf($txt['maintain_done'], $txt['move_topics_maintenance'])],
312
			];
313
		}
314
315
		// Set up the sub-template
316
		$context['sub_template'] = 'maintain_topics';
317
	}
318
319
	/**
320
	 * Find and try to fix all errors on the forum.
321
	 *
322
	 * - Forwards to repair boards controller.
323
	 */
324
	public function action_repair_display(): void
325
	{
326
		// Honestly, this should be done in the sub function.
327
		validateToken('admin-maint');
328
329
		$controller = new RepairBoards(new EventManager());
330
		$controller->setUser(User::$info);
331
		$controller->pre_dispatch();
332
		$controller->action_repairboards();
333
	}
334
335
	/**
336
	 * Wipes the current cache entries as best it can.
337
	 *
338
	 * - This only applies to our own cache entries, opcache, and data.
339
	 * - This action, like other maintenance tasks, may be called automatically
340
	 * by the task scheduler or manually by the admin in Maintenance area.
341
	 */
342
	public function action_cleancache_display(): void
343
	{
344
		global $context, $txt;
345
346
		checkSession();
347
		validateToken('admin-maint');
348
349
		// Just wipe the whole cache directory!
350
		Cache::instance()->clean();
351
352
		// Change the PWA stale so it will refresh (if enabled)
353
		setPWACacheStale(true);
354
355
		$context['maintenance_finished'] = $txt['maintain_cache'];
356
	}
357
358
	/**
359
	 * Empties all unimportant logs.
360
	 *
361
	 * - This action may be called periodically, by the tasks scheduler,
362
	 * or manually by the admin in Maintenance area.
363
	 */
364
	public function action_logs_display(): void
365
	{
366
		global $context, $txt;
367
368
		require_once(SUBSDIR . '/Maintenance.subs.php');
369
370
		checkSession();
371
		validateToken('admin-maint');
372
373
		// Maintenance time was scheduled!
374
		// When there is no intelligent life on this planet.
375
		// Apart from me, I mean.
376
		flushLogTables();
377
378
		updateSettings(['search_pointer' => 0]);
379
380
		$context['maintenance_finished'] = $txt['maintain_logs'];
381
	}
382
383
	/**
384
	 * Convert the column "body" of the table {db_prefix}messages from TEXT to
385
	 * MEDIUMTEXT and vice versa.
386
	 *
387
	 * What it does:
388
	 *
389
	 * - It requires the admin_forum permission.
390
	 * - This is needed only for MySQL.
391
	 * - During the conversion from MEDIUMTEXT to TEXT it check if any of the
392
	 * posts exceed the TEXT length and if so it aborts.
393
	 * - This action is linked from the maintenance screen (if it's applicable).
394
	 * - Accessed by ?action=admin;area=maintain;sa=database;activity=convertmsgbody.
395
	 *
396
	 * @uses the convert_msgbody sub template of the Admin template.
397
	 */
398
	public function action_convertmsgbody_display(): void
399
	{
400
		global $context, $txt, $modSettings, $time_start;
401
402
		// Show me your badge!
403
		isAllowedTo('admin_forum');
404
		$db = database();
405
406
		if ($db->supportMediumtext() === false)
0 ignored issues
show
The method supportMediumtext() does not exist on ElkArte\Database\QueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to ElkArte\Database\QueryInterface. ( Ignorable by Annotation )

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

406
		if ($db->/** @scrutinizer ignore-call */ supportMediumtext() === false)
Loading history...
407
		{
408
			return;
409
		}
410
411
		$body_type = '';
412
413
		// Find the body column "type" from the message table
414
		$colData = getMessageTableColumns();
415
		foreach ($colData as $column)
416
		{
417
			if ($column['name'] === 'body')
418
			{
419
				$body_type = $column['type'];
420
				break;
421
			}
422
		}
423
424
		$context['convert_to'] = $body_type === 'text' ? 'mediumtext' : 'text';
425
		if ($body_type === 'text' || isset($this->_req->post->do_conversion))
426
		{
427
			checkSession();
428
			validateToken('admin-maint');
429
430
			// Make it longer so we can do their limit.
431
			if ($body_type === 'text')
432
			{
433
				resizeMessageTableBody('mediumtext');
434
			}
435
			// Shorten the column so we can have (literally per record) less space occupied.
436
			else
437
			{
438
				resizeMessageTableBody('text');
439
			}
440
441
			$colData = getMessageTableColumns();
442
			foreach ($colData as $column)
443
			{
444
				if ($column['name'] === 'body')
445
				{
446
					$body_type = $column['type'];
447
				}
448
			}
449
450
			$context['maintenance_finished'] = $txt[$context['convert_to'] . '_title'];
451
			$context['convert_to'] = $body_type === 'text' ? 'mediumtext' : 'text';
452
			$context['convert_to_suggest'] = ($body_type !== 'text' && !empty($modSettings['max_messageLength']) && $modSettings['max_messageLength'] < 65536);
453
454
			return;
455
		}
456
457
		if (!isset($this->_req->post->do_conversion) || isset($this->_req->post->cont))
458
		{
459
			checkSession();
460
			if (!$this->_req->hasQuery('start'))
461
			{
462
				validateToken('admin-maint');
463
			}
464
			else
465
			{
466
				validateToken('admin-convertMsg');
467
			}
468
469
			$context['page_title'] = $txt['not_done_title'];
470
			$context['continue_post_data'] = '';
471
			$context['continue_countdown'] = 3;
472
			$context['sub_template'] = 'not_done';
473
474
			$increment = 500;
475
			$id_msg_exceeding = isset($this->_req->post->id_msg_exceeding) ? explode(',', $this->_req->post->id_msg_exceeding) : [];
476
			$max_msgs = countMessages();
477
			$start = $this->_req->getQuery('start', 'intval', 0);
478
479
			// Try for as much time as possible.
480
			detectServer()->setTimeLimit(600);
481
			while ($start < $max_msgs)
482
			{
483
				$id_msg_exceeding = detectExceedingMessages($start, $increment);
484
485
				$start += $increment;
486
487
				if (microtime(true) - $time_start > 3)
488
				{
489
					createToken('admin-convertMsg');
490
					$context['continue_post_data'] = '
491
						<input type="hidden" name="' . $context['admin-convertMsg_token_var'] . '" value="' . $context['admin-convertMsg_token'] . '" />
492
						<input type="hidden" name="' . $context['session_var'] . '" value="' . $context['session_id'] . '" />
493
						<input type="hidden" name="id_msg_exceeding" value="' . implode(',', $id_msg_exceeding) . '" />';
494
					$context['continue_get_data'] = '?action=admin;area=maintain;sa=database;activity=convertmsgbody;start=' . $start;
495
					$context['continue_percent'] = round(100 * $start / $max_msgs);
496
					$context['not_done_title'] = $txt['not_done_title'] . ' (' . $context['continue_percent'] . '%)';
497
498
					return;
499
				}
500
			}
501
502
			createToken('admin-maint');
503
			$context['page_title'] = $txt[$context['convert_to'] . '_title'];
504
			$context['sub_template'] = 'convert_msgbody';
505
506
			if (!empty($id_msg_exceeding))
507
			{
508
				if (count($id_msg_exceeding) > 100)
509
				{
510
					$query_msg = array_slice($id_msg_exceeding, 0, 100);
511
					$context['exceeding_messages_morethan'] = sprintf($txt['exceeding_messages_morethan'], count($id_msg_exceeding));
512
				}
513
				else
514
				{
515
					$query_msg = $id_msg_exceeding;
516
				}
517
518
				$context['exceeding_messages'] = getExceedingMessages($query_msg);
519
			}
520
		}
521
	}
522
523
	/**
524
	 * Optimizes all tables in the database and lists how much was saved.
525
	 *
526
	 * What it does:
527
	 *
528
	 * - It requires the admin_forum permission.
529
	 * - It shows as the maintain_forum admin area.
530
	 * - It is accessed from ?action=admin;area=maintain;sa=database;activity=optimize.
531
	 * - It also updates the optimize scheduled task such that the tables are not automatically optimized again too soon.
532
	 */
533
	public function action_optimize_display(): void
534
	{
535
		global $txt, $context;
536
537
		isAllowedTo('admin_forum');
538
539
		// Some validation
540
		checkSession();
541
		validateToken('admin-maint');
542
543
		ignore_user_abort(true);
544
545
		require_once(SUBSDIR . '/Maintenance.subs.php');
546
547
		$context['page_title'] = $txt['database_optimize'];
548
		$context['sub_template'] = 'optimize';
549
550
		$tables = getElkTables();
551
552
		// If there aren't any tables, then I believe that would mean the world has exploded...
553
		$context['num_tables'] = count($tables);
554
		if ($context['num_tables'] === 0)
555
		{
556
			throw new Exception('You appear to be running ElkArte in a flat file mode... fantastic!', false);
557
		}
558
559
		// For each table....
560
		$context['optimized_tables'] = [];
561
		$db_table = db_table();
562
563
		foreach ($tables as $table)
564
		{
565
			// Optimize the table!  We use backticks here because it might be a custom table.
566
			$data_freed = $db_table->optimize($table['table_name']);
567
568
			if ($data_freed > 0)
569
			{
570
				$context['optimized_tables'][] = [
571
					'name' => $table['table_name'],
572
					'data_freed' => $data_freed,
573
				];
574
			}
575
		}
576
577
		// Number of tables, etc....
578
		$txt['database_numb_tables'] = sprintf($txt['database_numb_tables'], $context['num_tables']);
579
		$context['num_tables_optimized'] = count($context['optimized_tables']);
580
581
		// Check that we don't auto-optimize again too soon!
582
		require_once(SUBSDIR . '/ScheduledTasks.subs.php');
583
		calculateNextTrigger('auto_optimize', true);
584
	}
585
586
	/**
587
	 * Recount many forum totals that can be recounted automatically without harm.
588
	 *
589
	 * What it does:
590
	 *
591
	 * - It requires the admin_forum permission.
592
	 * - It shows the maintain_forum admin area.
593
	 * - The function redirects back to ?action=admin;area=maintain when complete.
594
	 * - It is accessed via ?action=admin;area=maintain;sa=database;activity=recount.
595
	 *
596
	 * Totals recounted:
597
	 * - fixes for topics with wrong num_replies.
598
	 * - updates for num_posts and num_topics of all boards.
599
	 * - recounts personal_messages but not unread_messages.
600
	 * - repairs messages pointing to boards with topics pointing to other boards.
601
	 * - updates the last message posted in boards and children.
602
	 * - updates member count, latest member, topic count, and message count.
603
	 */
604
	public function action_recount_display(): void
605
	{
606
		global $txt, $context, $modSettings, $time_start;
607
608
		isAllowedTo('admin_forum');
609
		checkSession();
610
611
		// Functions
612
		require_once(SUBSDIR . '/Maintenance.subs.php');
613
		require_once(SUBSDIR . '/Topic.subs.php');
614
615
		// Validate the request or the loop
616
		if (!$this->_req->hasQuery('step'))
617
		{
618
			validateToken('admin-maint');
619
		}
620
		else
621
		{
622
			validateToken('admin-boardrecount');
623
		}
624
625
		// For the loop template
626
		$context['page_title'] = $txt['not_done_title'];
627
		$context['continue_post_data'] = '';
628
		$context['continue_countdown'] = 3;
629
		$context['sub_template'] = 'not_done';
630
631
		// Try for as much time as possible.
632
		detectServer()->setTimeLimit(600);
633
634
		// Step the number of topics at a time so things don't time out...
635
		$this->max_topics = getMaxTopicID();
636
		$this->increment = (int) min(max(50, ceil($this->max_topics / 4)), 2000);
637
638
		// An 8-step process should be 12 for the admin
639
		$this->total_steps = 8;
640
		$this->start = $this->_req->getQuery('start', 'inval', 0);
641
		$this->step = $this->_req->getQuery('step', 'intval', 0);
642
643
		// Get each topic with a wrong reply count and fix it
644
		if (empty($this->step))
645
		{
646
			// let's just do some at a time, though.
647
			while ($this->start < $this->max_topics)
648
			{
649
				recountApprovedMessages($this->start, $this->increment);
650
				recountUnapprovedMessages($this->start, $this->increment);
651
				$this->start += $this->increment;
652
653
				if (microtime(true) - $time_start > 3)
654
				{
655
					$percent = round((100 * $this->start / $this->max_topics) / $this->total_steps);
656
					$this->_buildContinue($percent, 0);
657
658
					return;
659
				}
660
			}
661
662
			// Done with step 0, reset start for the next one
663
			$this->start = 0;
664
		}
665
666
		// Update the post-count of each board.
667
		if ($this->step <= 1)
668
		{
669
			if (empty($this->start))
670
			{
671
				resetBoardsCounter('num_posts');
672
			}
673
674
			while ($this->start < $this->max_topics)
675
			{
676
				// Recount the posts
677
				updateBoardsCounter('posts', $this->start, $this->increment);
678
				$this->start += $this->increment;
679
680
				if (microtime(true) - $time_start > 3)
681
				{
682
					$percent = round((200 + 100 * $this->start / $this->max_topics) / $this->total_steps);
683
					$this->_buildContinue($percent, 1);
684
685
					return;
686
				}
687
			}
688
689
			// Done with step 1, reset start for the next one
690
			$this->start = 0;
691
		}
692
693
		// Update the topic count of each board.
694
		if ($this->step <= 2)
695
		{
696
			if (empty($this->start))
697
			{
698
				resetBoardsCounter('num_topics');
699
			}
700
701
			while ($this->start < $this->max_topics)
702
			{
703
				updateBoardsCounter('topics', $this->start, $this->increment);
704
				$this->start += $this->increment;
705
706
				if (microtime(true) - $time_start > 3)
707
				{
708
					$percent = round((300 + 100 * $this->start / $this->max_topics) / $this->total_steps);
709
					$this->_buildContinue($percent, 2);
710
711
					return;
712
				}
713
			}
714
715
			// Done with step 2, reset start for the next one
716
			$this->start = 0;
717
		}
718
719
		// Update the unapproved post-count of each board.
720
		if ($this->step <= 3)
721
		{
722
			if (empty($this->start))
723
			{
724
				resetBoardsCounter('unapproved_posts');
725
			}
726
727
			while ($this->start < $this->max_topics)
728
			{
729
				updateBoardsCounter('unapproved_posts', $this->start, $this->increment);
730
				$this->start += $this->increment;
731
732
				if (microtime(true) - $time_start > 3)
733
				{
734
					$percent = round((400 + 100 * $this->start / $this->max_topics) / $this->total_steps);
735
					$this->_buildContinue($percent, 3);
736
737
					return;
738
				}
739
			}
740
741
			// Done with step 3, reset start for the next one
742
			$this->start = 0;
743
		}
744
745
		// Update the unapproved topic count of each board.
746
		if ($this->step <= 4)
747
		{
748
			if (empty($this->start))
749
			{
750
				resetBoardsCounter('unapproved_topics');
751
			}
752
753
			while ($this->start < $this->max_topics)
754
			{
755
				updateBoardsCounter('unapproved_topics', $this->start, $this->increment);
756
				$this->start += $this->increment;
757
758
				if (microtime(true) - $time_start > 3)
759
				{
760
					$percent = round((500 + 100 * $this->start / $this->max_topics) / $this->total_steps);
761
					$this->_buildContinue($percent, 4);
762
763
					return;
764
				}
765
			}
766
767
			// Done with step 4, reset start for the next one
768
			$this->start = 0;
769
		}
770
771
		// Get all members with the wrong number of personal messages.
772
		if ($this->step <= 5)
773
		{
774
			updatePersonalMessagesCounter();
775
776
			// Done with step 5, reset start for the next one
777
			$this->start = 0;
778
			if (microtime(true) - $time_start > 3)
779
			{
780
				$percent = round(700 / $this->total_steps);
781
				$this->_buildContinue($percent, 6);
782
783
				return;
784
			}
785
		}
786
787
		// Any messages pointing to the wrong board?
788
		if ($this->step <= 6)
789
		{
790
			while ($this->start < $modSettings['maxMsgID'])
791
			{
792
				// Use the controller's start pointer, not the raw request
793
				updateMessagesBoardID($this->start, $this->increment);
794
				$this->start += $this->increment;
795
796
				if (microtime(true) - $time_start > 3)
797
				{
798
					$percent = round((700 + 100 * $this->start / $modSettings['maxMsgID']) / $this->total_steps);
799
					$this->_buildContinue($percent, 6);
800
801
					return;
802
				}
803
			}
804
805
			// Done with step 6, reset start for the next one
806
			$this->start = 0;
807
		}
808
809
		updateBoardsLastMessage();
810
811
		// Update all the basic statistics.
812
		require_once(SUBSDIR . '/Members.subs.php');
813
		updateMemberStats();
814
		require_once(SUBSDIR . '/Messages.subs.php');
815
		updateMessageStats();
816
		require_once(SUBSDIR . '/Topic.subs.php');
817
		updateTopicStats();
818
819
		// Finally, update the latest event times.
820
		require_once(SUBSDIR . '/ScheduledTasks.subs.php');
821
		calculateNextTrigger();
822
823
		// Ta-da
824
		redirectexit('action=admin;area=maintain;sa=routine;done=recount');
825
	}
826
827
	/**
828
	 * Helper function for the recount process, build the continued values for
829
	 * the template
830
	 *
831
	 * @param int $percent percent done
832
	 * @param int $step step we are on
833
	 */
834
	private function _buildContinue(int $percent, int $step): void
835
	{
836
		global $context, $txt;
837
838
		createToken('admin-boardrecount');
839
840
		$context['continue_post_data'] = '
841
			<input type="hidden" name="' . $context['admin-boardrecount_token_var'] . '" value="' . $context['admin-boardrecount_token'] . '" />
842
			<input type="hidden" name="' . $context['session_var'] . '" value="' . $context['session_id'] . '" />';
843
		$context['continue_get_data'] = '?action=admin;area=maintain;sa=routine;activity=recount;step=' . $step . ';start=' . $this->start;
844
		$context['continue_percent'] = $percent;
845
		$context['not_done_title'] = $txt['not_done_title'] . ' (' . $context['continue_percent'] . '%)';
846
	}
847
848
	/**
849
	 * Re-attribute posts to the user sent from the maintenance page.
850
	 */
851
	public function action_reattribute_display(): void
852
	{
853
		global $context, $txt;
854
855
		checkSession();
856
857
		$validator = new DataValidator();
858
		$validator->sanitation_rules(['posts' => 'empty', 'type' => 'trim', 'from_email' => 'trim', 'from_name' => 'trim', 'to' => 'trim']);
859
		$validator->validation_rules(['from_email' => 'valid_email', 'from_name' => 'required', 'to' => 'required', 'type' => 'contains[name,email]']);
860
		$validator->validate($this->_req->post);
861
862
		// Fetch the Mr. Clean values
863
		$our_post = array_replace((array) $this->_req->post, $validator->validation_data());
864
865
		// Do we have a valid set of options to continue?
866
		if (($our_post['type'] === 'name' && !empty($our_post['from_name'])) || ($our_post['type'] === 'email' && !$validator->validation_errors('from_email')))
867
		{
868
			// Find the member.
869
			require_once(SUBSDIR . '/Auth.subs.php');
870
			$members = findMembers($our_post['to']);
871
872
			// No members, no further
873
			if (empty($members))
874
			{
875
				throw new Exception('reattribute_cannot_find_member');
876
			}
877
878
			$memID = array_shift($members);
879
			$memID = $memID['id'];
880
881
			$email = $our_post['type'] === 'email' ? $our_post['from_email'] : '';
882
			$memberName = $our_post['type'] === 'name' ? $our_post['from_name'] : '';
883
884
			// Now call the reattribute function.
885
			require_once(SUBSDIR . '/Members.subs.php');
886
			reattributePosts($memID, $email, $memberName, !$our_post['posts']);
887
888
			$context['maintenance_finished'] = [
889
				'errors' => [sprintf($txt['maintain_done'], $txt['maintain_reattribute_posts'])],
890
			];
891
		}
892
		else
893
		{
894
			// Show them the correct error
895
			if ($our_post['type'] === 'name' && empty($our_post['from_name']))
896
			{
897
				$error = $validator->validation_errors(['from_name', 'to']);
898
			}
899
			else
900
			{
901
				$error = $validator->validation_errors(['from_email', 'to']);
902
			}
903
904
			$context['maintenance_finished'] = [
905
				'errors' => $error,
906
				'type' => 'minor',
907
			];
908
		}
909
	}
910
911
	/**
912
	 * Handling function for the backup stuff.
913
	 *
914
	 * - It requires an administrator and the session hash by post.
915
	 * - This method simply forwards to DumpDatabase2().
916
	 */
917
	public function action_backup_display(): ?bool
918
	{
919
		validateToken('admin-maint');
920
921
		// Administrators only!
922
		if (!allowedTo('admin_forum'))
923
		{
924
			throw new Exception('no_dump_database', 'critical');
925
		}
926
927
		checkSession();
928
929
		// Validate access
930
		if (!defined('I_KNOW_IT_MAY_BE_UNSAFE') && $this->_validate_access() === false)
931
		{
932
			$this->action_database();
933
			return null;
934
		}
935
936
		require_once(SUBSDIR . '/Admin.subs.php');
937
		emailAdmins('admin_backup_database', [
938
			'BAK_REALNAME' => $this->user->name
939
		]);
940
941
		logAction('database_backup', ['member' => $this->user->id], 'admin');
942
		require_once(SOURCEDIR . '/DumpDatabase.php');
943
		DumpDatabase2();
944
945
		// Should not get here as DumpDatabase2 exits
946
		return true;
947
	}
948
949
	/**
950
	 * Validates the user can make an FTP connection with the supplied uid/pass
951
	 *
952
	 * - Used as an extra layer of security when performing backups
953
	 */
954
	private function _validate_access(): bool
955
	{
956
		global $context, $txt;
957
958
		$ftp = new FtpConnection($this->_req->post->ftp_server, $this->_req->post->ftp_port, $this->_req->post->ftp_username, $this->_req->post->ftp_password);
959
960
		// No errors on the connection, id/pass are good
961
		// I know, I know... but a lot of people want to type /home/xyz/... which is wrong but logical.
962
		if ($ftp->error === false && !$ftp->chdir($this->_req->post->ftp_path))
963
		{
964
			$ftp->chdir(preg_replace('~^/home[2]?/[^/]+~', '', $this->_req->post->ftp_path));
965
		}
966
967
		// If we had an error...
968
		if ($ftp->error !== false)
969
		{
970
			Txt::load('Packages');
971
			$ftp_error = $ftp->last_message ?? $txt['package_ftp_' . $ftp->error] ?? '';
0 ignored issues
show
Are you sure $ftp->error of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

971
			$ftp_error = $ftp->last_message ?? $txt['package_ftp_' . /** @scrutinizer ignore-type */ $ftp->error] ?? '';
Loading history...
972
973
			// Fill the boxes for an FTP connection with data from the previous attempt
974
			$context['package_ftp'] = [
975
				'form_elements_only' => 1,
976
				'server' => $this->_req->post->ftp_server,
977
				'port' => $this->_req->post->ftp_port,
978
				'username' => $this->_req->post->ftp_username,
979
				'path' => $this->_req->post->ftp_path,
980
				'error' => empty($ftp_error) ? null : $ftp_error,
981
			];
982
983
			return false;
984
		}
985
986
		return true;
987
	}
988
989
	/**
990
	 * Supporting function for the database maintenance area.
991
	 */
992
	public function action_database(): void
993
	{
994
		global $context, $modSettings, $maintenance;
995
996
		// We need this, really...
997
		require_once(SUBSDIR . '/Maintenance.subs.php');
998
999
		// Set up the sub-template
1000
		$context['sub_template'] = 'maintain_database';
1001
		$db = database();
1002
1003
		if ($db->supportMediumtext())
1004
		{
1005
			$body_type = fetchBodyType();
1006
1007
			$context['convert_to'] = $body_type === 'text' ? 'mediumtext' : 'text';
1008
			$context['convert_to_suggest'] = ($body_type !== 'text' && !empty($modSettings['max_messageLength']) && $modSettings['max_messageLength'] < 65536);
1009
		}
1010
1011
		// Check a few things to give advices before make a backup
1012
		// If safe mod is enabled, the external tool is *always* the best (and probably the only) solution
1013
		$context['safe_mode_enable'] = false;
1014
1015
		// This is just a...guess
1016
		$messages = countMessages();
1017
1018
		// 256 is what we use in the backup script
1019
		detectServer()->setMemoryLimit('256M');
1020
		$memory_limit = memoryReturnBytes(ini_get('memory_limit')) / (1024 * 1024);
1021
1022
		// Zip limit is set to more or less 1/4th the size of the available memory * 1500
1023
		// 1500 is an estimate of the number of messages that generates a database of 1 MB (yeah, I know IT'S AN ESTIMATION!!!)
1024
		// Why that? Because the only reliable zip package is the one sent out the first time,
1025
		// so when the backup takes 1/5th (just to stay on the safe side) of the memory available
1026
		$zip_limit = $memory_limit * 1500 / 5;
1027
1028
		// Here is more tricky: it depends on many factors, but the main idea is that
1029
		// if it takes "too long" the backup is not reliable. So, I know that on my computer it takes
1030
		// 20 minutes to back up 2.5 GB; of course, my computer is not representative, so I'll multiply by 4 the time.
1031
		// I would consider "too long" 5 minutes (I know it can be a long time, but let's start with that):
1032
		// 80 minutes for a 2.5 GB and a 5-minute limit means 160 MB approx
1033
		$plain_limit = 240000;
1034
1035
		// Last thing: are we able to gain time?
1036
		$current_time_limit = (int) ini_get('max_execution_time');
1037
		$new_time_limit = detectServer()->setTimeLimit(159); //something strange just to be sure
1038
		detectServer()->setTimeLimit($current_time_limit);
1039
1040
		$context['use_maintenance'] = 0;
1041
1042
		// External tool if:
1043
		//  * cannot change the execution time OR
1044
		//  * cannot reset timeout
1045
		if (empty($new_time_limit) || ($current_time_limit === $new_time_limit && !function_exists('apache_reset_timeout')))
1046
		{
1047
			$context['suggested_method'] = 'use_external_tool';
1048
		}
1049
		elseif ($zip_limit < $plain_limit && $messages < $zip_limit)
1050
		{
1051
			$context['suggested_method'] = 'zipped_file';
1052
		}
1053
		elseif ($zip_limit > $plain_limit || ($zip_limit < $plain_limit && $plain_limit < $messages))
1054
		{
1055
			$context['suggested_method'] = 'use_external_tool';
1056
			$context['use_maintenance'] = empty($maintenance) ? 2 : 0;
1057
		}
1058
		else
1059
		{
1060
			$context['use_maintenance'] = 1;
1061
			$context['suggested_method'] = 'plain_text';
1062
		}
1063
1064
		theme()->getTemplates()->load('Packages');
1065
		Txt::load('Packages');
1066
1067
		// $context['package_ftp'] may be set action_backup_display when an error occurs
1068
		if (!isset($context['package_ftp']))
1069
		{
1070
			$context['package_ftp'] = [
1071
				'form_elements_only' => true,
1072
				'server' => '',
1073
				'port' => '',
1074
				'username' => $modSettings['package_username'] ?? '',
1075
				'path' => '',
1076
				'error' => '',
1077
			];
1078
		}
1079
1080
		$context['skip_security'] = defined('I_KNOW_IT_MAY_BE_UNSAFE');
1081
	}
1082
1083
	/**
1084
	 * Removing old and inactive members.
1085
	 */
1086
	public function action_purgeinactive_display(): void
1087
	{
1088
		global $context, $txt;
1089
1090
		checkSession();
1091
		validateToken('admin-maint');
1092
1093
		// Start with checking and cleaning what was sent
1094
		$validator = new DataValidator();
1095
		$validator->sanitation_rules(['maxdays' => 'intval']);
1096
		$validator->validation_rules(['maxdays' => 'required', 'groups' => 'isarray', 'del_type' => 'required']);
1097
1098
		// Validator says, you can pass or not
1099
		if ($validator->validate($this->_req->post))
1100
		{
1101
			// Get the clean data
1102
			$our_post = array_replace((array) $this->_req->post, $validator->validation_data());
1103
1104
			require_once(SUBSDIR . '/Maintenance.subs.php');
1105
			require_once(SUBSDIR . '/Members.subs.php');
1106
1107
			$groups = [];
1108
			foreach ($our_post['groups'] as $id => $dummy)
1109
			{
1110
				$groups[] = (int) $id;
1111
			}
1112
1113
			$time_limit = (time() - ($our_post['maxdays'] * 24 * 3600));
1114
			$members = purgeMembers($our_post['del_type'], $groups, $time_limit);
1115
			deleteMembers($members);
1116
1117
			$context['maintenance_finished'] = [
1118
				'errors' => [sprintf($txt['maintain_done'], $txt['maintain_members'])],
1119
			];
1120
		}
1121
		else
1122
		{
1123
			$context['maintenance_finished'] = [
1124
				'errors' => $validator->validation_errors(),
1125
				'type' => 'minor',
1126
			];
1127
		}
1128
	}
1129
1130
	/**
1131
	 * This method takes care of removal of old posts.
1132
	 * They're very, very old, perhaps even older.
1133
	 */
1134
	public function action_pruneold_display(): void
1135
	{
1136
		validateToken('admin-maint');
1137
1138
		isAllowedTo('admin_forum');
1139
		checkSession('post', 'admin');
1140
1141
		// No boards at all?  Forget it then :/.
1142
		if (empty($this->_req->post->boards))
1143
		{
1144
			redirectexit('action=admin;area=maintain;sa=topics');
1145
		}
1146
1147
		$boards = array_keys($this->_req->post->boards);
1148
1149
		if (!isset($this->_req->post->delete_type) || !in_array($this->_req->post->delete_type, ['moved', 'nothing', 'locked']))
1150
		{
1151
			$delete_type = 'nothing';
1152
		}
1153
		else
1154
		{
1155
			$delete_type = $this->_req->post->delete_type;
1156
		}
1157
1158
		$exclude_stickies = isset($this->_req->post->delete_old_not_sticky);
1159
1160
		// @todo what is the minimum for maxdays? Maybe throw an error?
1161
		$older_than = time() - 3600 * 24 * max($this->_req->getPost('maxdays', 'intval', 0), 1);
1162
1163
		require_once(SUBSDIR . '/Topic.subs.php');
1164
		removeOldTopics($boards, $delete_type, $exclude_stickies, $older_than);
1165
1166
		// Log an action into the moderation log.
1167
		logAction('pruned', ['days' => max($this->_req->getPost('maxdays', 'intval', 0), 1)]);
1168
1169
		redirectexit('action=admin;area=maintain;sa=topics;done=purgeold');
1170
	}
1171
1172
	/**
1173
	 * Moves topics from one board to another.
1174
	 *
1175
	 * @uses not_done template to pause the process.
1176
	 */
1177
	public function action_massmove_display(): void
1178
	{
1179
		global $context, $txt, $time_start;
1180
1181
		// Only admins.
1182
		isAllowedTo('admin_forum');
1183
1184
		// And valid requests
1185
		checkSession();
1186
1187
		// Set up to the context.
1188
		$context['page_title'] = $txt['not_done_title'];
1189
		$context['continue_countdown'] = 3;
1190
		$context['continue_post_data'] = '';
1191
		$context['continue_get_data'] = '';
1192
		$context['sub_template'] = 'not_done';
1193
		$context['start'] = $this->_req->getQuery('start', 'intval', 0);
1194
1195
		// First time we do this?
1196
		$id_board_from = $this->_req->getPost('id_board_from', 'intval', $this->_req->getQuery('id_board_from', 'intval', 0));
1197
		$id_board_to = $this->_req->getPost('id_board_to', 'intval', $this->_req->getQuery('id_board_to', 'intval', 0));
1198
1199
		// No boards, then this is your stop.
1200
		if (empty($id_board_from) || empty($id_board_to))
1201
		{
1202
			return;
1203
		}
1204
1205
		// These will be needed
1206
		require_once(SUBSDIR . '/Maintenance.subs.php');
1207
		require_once(SUBSDIR . '/Topic.subs.php');
1208
1209
		// How many topics are we moving?
1210
		$total_topics = $this->_req->getQuery('totaltopics', 'intval', 0);
1211
		if (empty($total_topics) || $context['start'] === 0)
1212
		{
1213
			validateToken('admin-maint');
1214
			$total_topics = countTopicsFromBoard($id_board_from);
1215
		}
1216
		else
1217
		{
1218
			$total_topics = $this->_req->getQuery('totaltopics', 'intval');
1219
			validateToken('admin_movetopics');
1220
		}
1221
1222
		// We have topics to move so start the process.
1223
		if (!empty($total_topics))
1224
		{
1225
			while ($context['start'] <= $total_topics)
1226
			{
1227
				// Let's get the next 10 topics.
1228
				$topics = getTopicsToMove($id_board_from);
1229
1230
				// Just return if we don't have any topics left to move.
1231
				if (empty($topics))
1232
				{
1233
					break;
1234
				}
1235
1236
				// Let's move them.
1237
				moveTopics($topics, $id_board_to);
1238
1239
				// Increase the counter
1240
				$context['start'] += 10;
1241
1242
				// If this is really taking some time, show the pause screen
1243
				if (microtime(true) - $time_start > 3)
1244
				{
1245
					// What's the percent?
1246
					$context['continue_percent'] = round(100 * ($context['start'] / $total_topics), 1);
1247
1248
					// Set up for the form
1249
					$context['continue_get_data'] = '?action=admin;area=maintain;sa=topics;activity=massmove;id_board_from=' . $id_board_from . ';id_board_to=' . $id_board_to . ';totaltopics=' . $total_topics . ';start=' . $context['start'];
1250
					$context['continue_post_data'] = '
1251
						<input type="hidden" name="' . $context['session_var'] . '" value="' . $context['session_id'] . '" />';
1252
1253
					// Let the template system do it's thang.
1254
					return;
1255
				}
1256
			}
1257
		}
1258
1259
		// Don't confuse admins by having an out-of-date cache.
1260
		Cache::instance()->remove('board-' . $id_board_from);
1261
		Cache::instance()->remove('board-' . $id_board_to);
1262
1263
		redirectexit('action=admin;area=maintain;sa=topics;done=massmove');
1264
	}
1265
1266
	/**
1267
	 * Generates a list of integration hooks for display
1268
	 *
1269
	 * - Accessed through ?action=admin;area=maintain;sa=hooks;
1270
	 */
1271
	public function action_hooks(): void
1272
	{
1273
		global $context, $txt;
1274
1275
		require_once(SUBSDIR . '/AddonSettings.subs.php');
1276
1277
		$context['filter_url'] = '';
1278
		$context['current_filter'] = '';
1279
1280
		// Get the list of the current system hooks, filter them if needed
1281
		$currentHooks = get_integration_hooks();
1282
		$filter = $this->_req->getQuery('filter', 'trim', '');
1283
		if (array_key_exists($filter, $currentHooks))
1284
		{
1285
			$context['filter_url'] = ';filter=' . $filter;
1286
			$context['current_filter'] = $filter;
1287
		}
1288
1289
		$list_options = [
1290
			'id' => 'list_integration_hooks',
1291
			'title' => $txt['maintain_sub_hooks_list'],
1292
			'items_per_page' => 20,
1293
			'base_href' => getUrl('admin', ['action' => 'admin', 'area' => 'maintain', 'sa' => 'hooks', $context['filter_url'], '{session_data}']),
1294
			'default_sort_col' => 'hook_name',
1295
			'get_items' => [
1296
				'function' => fn($start, $items_per_page, $sort) => $this->list_getIntegrationHooks($start, $items_per_page, $sort),
1297
			],
1298
			'get_count' => [
1299
				'function' => fn() => $this->list_getIntegrationHooksCount(),
1300
			],
1301
			'no_items_label' => $txt['hooks_no_hooks'],
1302
			'columns' => [
1303
				'hook_name' => [
1304
					'header' => [
1305
						'value' => $txt['hooks_field_hook_name'],
1306
					],
1307
					'data' => [
1308
						'db' => 'hook_name',
1309
					],
1310
					'sort' => [
1311
						'default' => 'hook_name',
1312
						'reverse' => 'hook_name DESC',
1313
					],
1314
				],
1315
				'function_name' => [
1316
					'header' => [
1317
						'value' => $txt['hooks_field_function_name'],
1318
					],
1319
					'data' => [
1320
						'function' => static function ($data) {
1321
							global $txt;
1322
1323
							if (!empty($data['included_file']))
1324
							{
1325
								return $txt['hooks_field_function'] . ': ' . $data['real_function'] . '<br />' . $txt['hooks_field_included_file'] . ': ' . $data['included_file'];
1326
							}
1327
1328
							return $data['real_function'];
1329
						},
1330
					],
1331
					'sort' => [
1332
						'default' => 'function_name',
1333
						'reverse' => 'function_name DESC',
1334
					],
1335
				],
1336
				'file_name' => [
1337
					'header' => [
1338
						'value' => $txt['hooks_field_file_name'],
1339
					],
1340
					'data' => [
1341
						'db' => 'file_name',
1342
					],
1343
					'sort' => [
1344
						'default' => 'file_name',
1345
						'reverse' => 'file_name DESC',
1346
					],
1347
				],
1348
				'status' => [
1349
					'header' => [
1350
						'value' => $txt['hooks_field_hook_exists'],
1351
						'class' => 'nowrap',
1352
					],
1353
					'data' => [
1354
						'function' => static fn($data) => '<i class="icon i-post_moderation_' . $data['status'] . '" title="' . $data['img_text'] . '"></i>',
1355
						'class' => 'centertext',
1356
					],
1357
					'sort' => [
1358
						'default' => 'status',
1359
						'reverse' => 'status DESC',
1360
					],
1361
				],
1362
			],
1363
			'additional_rows' => [
1364
				[
1365
					'position' => 'after_title',
1366
					'value' => $txt['hooks_disable_legend'] . ':
1367
					<ul>
1368
						<li>
1369
							<i class="icon i-post_moderation_allow" title="' . $txt['hooks_active'] . '"></i>' . $txt['hooks_disable_legend_exists'] . '
1370
						</li>
1371
						<li>
1372
							<i class="icon i-post_moderation_deny col" title="' . $txt['hooks_missing'] . '"></i>' . $txt['hooks_disable_legend_missing'] . '
1373
						</li>
1374
					</ul>'
1375
				],
1376
			],
1377
		];
1378
1379
		createList($list_options);
1380
1381
		$context['page_title'] = $txt['maintain_sub_hooks_list'];
1382
		$context['sub_template'] = 'show_list';
1383
		$context['default_list'] = 'list_integration_hooks';
1384
	}
1385
1386
	/**
1387
	 * Callback for createList(). Called by action_hooks
1388
	 *
1389
	 * @param int $start The item to start with (for pagination purposes)
1390
	 * @param int $items_per_page The number of items to show per page
1391
	 * @param string $sort A string indicating how to sort the results
1392
	 *
1393
	 * @return array
1394
	 */
1395
	public function list_getIntegrationHooks(int $start, int $items_per_page, string $sort): array
1396
	{
1397
		return list_integration_hooks_data($start, $items_per_page, $sort);
1398
	}
1399
1400
	/**
1401
	 * Simply returns the total count of integration hooks
1402
	 * Callback for createList().
1403
	 *
1404
	 * @return int
1405
	 */
1406
	public function list_getIntegrationHooksCount(): int
1407
	{
1408
		global $context;
1409
1410
		$context['filter'] = $this->_req->getQuery('filter', 'trim|strval', false);
1411
1412
		return integration_hooks_count($context['filter']);
1413
	}
1414
1415
	/**
1416
	 * Recalculate all members post-counts
1417
	 *
1418
	 * What it does:
1419
	 *
1420
	 * - It requires the admin_forum permission.
1421
	 * - Recounts all posts for members found in the message table
1422
	 * - Updates the members post-count record in the members table
1423
	 * - Honors the boards post-count flag
1424
	 * - Does not count posts in the recycle bin
1425
	 * - Zeros post-counts for all members with no posts in the message table
1426
	 * - Runs as a delayed loop to avoid server overload
1427
	 * - Uses the not_done template in Admin.template
1428
	 * - Redirects back to action=admin;area=maintain;sa=members when complete.
1429
	 * - Accessed via ?action=admin;area=maintain;sa=members;activity=recountposts
1430
	 */
1431
	public function action_recountposts_display(): void
1432
	{
1433
		global $txt, $context;
1434
1435
		// Check the session
1436
		checkSession();
1437
1438
		// Set up to the context for the pause screen
1439
		$context['page_title'] = $txt['not_done_title'];
1440
		$context['continue_countdown'] = 3;
1441
		$context['continue_get_data'] = '';
1442
		$context['sub_template'] = 'not_done';
1443
1444
		// Init, do 200 members in a bunch
1445
		$increment = 200;
1446
		$start = $this->_req->getQuery('start', 'intval', 0);
1447
1448
		// Ask for some extra time, on big boards this may take a bit
1449
		detectServer()->setTimeLimit(600);
1450
		Debug::instance()->off();
1451
1452
		// The functions here will come in handy
1453
		require_once(SUBSDIR . '/Maintenance.subs.php');
1454
1455
		// Only run this query if we don't have the total number of members that have posted
1456
		if (!isset($_SESSION['total_members']) || $start === 0)
1457
		{
1458
			validateToken('admin-maint');
1459
			$total_members = countContributors();
1460
			$_SESSION['total_member'] = $total_members;
1461
		}
1462
		else
1463
		{
1464
			validateToken('admin-recountposts');
1465
			$total_members = $this->_req->session->total_members;
1466
		}
1467
1468
		// Let's get the next group of members and determine their post-count
1469
		// (from the boards that have post-count enabled, of course).
1470
		$total_rows = updateMembersPostCount($start, $increment);
1471
1472
		// Continue?
1473
		if ($total_rows === $increment)
1474
		{
1475
			createToken('admin-recountposts');
1476
1477
			$start += $increment;
1478
			$context['continue_get_data'] = '?action=admin;area=maintain;sa=members;activity=recountposts;start=' . $start;
1479
			$context['continue_percent'] = round(100 * $start / $total_members);
1480
			$context['not_done_title'] = $txt['not_done_title'] . ' (' . $context['continue_percent'] . '%)';
1481
			$context['continue_post_data'] = '<input type="hidden" name="' . $context['admin-recountposts_token_var'] . '" value="' . $context['admin-recountposts_token'] . '" />
1482
				<input type="hidden" name="' . $context['session_var'] . '" value="' . $context['session_id'] . '" />';
1483
1484
			Debug::instance()->on();
1485
1486
			return;
1487
		}
1488
1489
		// No countable posts? set posts counter to 0
1490
		updateZeroPostMembers();
1491
1492
		Debug::instance()->on();
1493
1494
		// All done, clean up and go back to maintenance
1495
		unset($_SESSION['total_members']);
1496
		redirectexit('action=admin;area=maintain;sa=members;done=recountposts');
1497
	}
1498
}
1499