Passed
Push — master ( bcff1e...74dea7 )
by Daimona
01:53
created

UpdateList::getMissingGroups()   B

Complexity

Conditions 8
Paths 11

Size

Total Lines 28
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 19
nc 11
nop 0
dl 0
loc 28
rs 8.4444
c 0
b 0
f 0
1
<?php declare( strict_types=1 );
2
3
namespace BotRiconferme\Task;
4
5
use BotRiconferme\TaskHelper\TaskResult;
6
use BotRiconferme\Wiki\Page\PageBotList;
7
use Generator;
8
9
/**
10
 * Updates the JSON list, adding and removing dates according to the API list of privileged people
11
 */
12
class UpdateList extends Task {
13
	private const NON_GROUP_KEYS = [ 'override', 'override-perm', 'aliases' ];
14
15
	/**
16
	 * @var array The JSON list
17
	 * @phan-var array<string,array<string,string|string[]>>
18
	 */
19
	private $botList;
20
	/** @var array[] The list from the API request */
21
	private $actualList;
22
23
	/**
24
	 * @inheritDoc
25
	 */
26
	protected function getSubtasksMap(): array {
27
		// Everything is done here.
28
		return [];
29
	}
30
31
	/**
32
	 * @inheritDoc
33
	 */
34
	public function runInternal(): int {
35
		$this->actualList = $this->getActualAdmins();
36
		$pageBotList = $this->getBotList();
37
		$this->botList = $pageBotList->getDecodedContent();
38
39
		$missing = $this->getMissingGroups();
40
		$extra = $this->getExtraGroups();
41
42
		$newContent = $this->getNewContent( $missing, $extra );
43
44
		if ( $newContent === $this->botList ) {
45
			return TaskResult::STATUS_NOTHING;
46
		}
47
48
		$this->getLogger()->info( 'Updating admin list' );
49
50
		$pageBotList->edit( [
51
			'text' => json_encode( $newContent ),
52
			'summary' => $this->msg( 'list-update-summary' )->text()
53
		] );
54
55
		return $this->errors ? TaskResult::STATUS_ERROR : TaskResult::STATUS_GOOD;
56
	}
57
58
	/**
59
	 * @return string[][]
60
	 * @phan-return array<string,string[]>
61
	 */
62
	protected function getActualAdmins(): array {
63
		$params = [
64
			'action' => 'query',
65
			'list' => 'allusers',
66
			'augroup' => 'sysop',
67
			'auprop' => 'groups',
68
			'aulimit' => 'max',
69
		];
70
71
		$req = $this->getWiki()->getRequestFactory()->createStandaloneRequest( $params );
72
		return $this->extractAdmins( $req->executeAsQuery() );
73
	}
74
75
	/**
76
	 * @param Generator $data
77
	 * @return string[][]
78
	 * @phan-return array<string,string[]>
79
	 */
80
	protected function extractAdmins( Generator $data ): array {
81
		$ret = [];
82
		$blacklist = $this->getOpt( 'exclude-admins' );
83
		foreach ( $data as $u ) {
84
			if ( in_array( $u->name, $blacklist, true ) ) {
85
				continue;
86
			}
87
			$interestingGroups = array_intersect( $u->groups, [ 'sysop', 'bureaucrat', 'checkuser' ] );
88
			$ret[ $u->name ] = array_values( $interestingGroups );
89
		}
90
		return $ret;
91
	}
92
93
	/**
94
	 * Populate a list of new admins missing from the JSON list and their groups
95
	 *
96
	 * @return string[][]
97
	 */
98
	protected function getMissingGroups(): array {
99
		$missing = [];
100
		foreach ( $this->actualList as $admin => $data ) {
101
			$missingProps = array_diff( $data, array_keys( $this->botList[$admin] ?? [] ) );
102
			$missingGroups = array_diff( $missingProps, self::NON_GROUP_KEYS );
103
104
			foreach ( $missingGroups as $group ) {
105
				$ts = $this->getFlagDate( $admin, $group );
106
				if ( $ts === null ) {
107
					$aliases = $data['aliases'] ?? [];
108
					if ( $aliases ) {
109
						$this->getLogger()->info( "No $group flag date for $admin, trying aliases" );
110
						foreach ( $aliases as $alias ) {
111
							$ts = $this->getFlagDate( $alias, $group );
112
							if ( $ts !== null ) {
113
								break;
114
							}
115
						}
116
					}
117
					if ( $ts === null ) {
118
						$this->errors[] = "$group flag date unavailable for $admin";
119
						continue;
120
					}
121
				}
122
				$missing[ $admin ][ $group ] = $ts;
123
			}
124
		}
125
		return $missing;
126
	}
127
128
	/**
129
	 * Get the flag date for the given admin and group.
130
	 *
131
	 * @param string $admin
132
	 * @param string $group
133
	 * @return string|null
134
	 */
135
	protected function getFlagDate( string $admin, string $group ): ?string {
136
		$this->getLogger()->info( "Retrieving $group flag date for $admin" );
137
138
		$wiki = $this->getWiki();
139
		if ( $group === 'checkuser' ) {
140
			$wiki = $this->getWikiGroup()->getCentralWiki();
141
			$admin .= $wiki->getLocalUserIdentifier();
142
		}
143
144
		$params = [
145
			'action' => 'query',
146
			'list' => 'logevents',
147
			'leprop' => 'timestamp|details',
148
			'leaction' => 'rights/rights',
149
			'letitle' => "User:$admin",
150
			'lelimit' => 'max'
151
		];
152
153
		$data = $wiki->getRequestFactory()->createStandaloneRequest( $params )->executeAsQuery();
154
		$ts = $this->extractTimestamp( $data, $group );
155
156
		return $ts !== null ? date( 'd/m/Y', strtotime( $ts ) ) : null;
157
	}
158
159
	/**
160
	 * Find the actual timestamp when the user was given the searched group
161
	 *
162
	 * @param Generator $data
163
	 * @param string $group
164
	 * @return string|null
165
	 */
166
	private function extractTimestamp( Generator $data, string $group ): ?string {
167
		$ts = null;
168
		foreach ( $data as $entry ) {
169
			if (
170
				isset( $entry->params ) &&
171
				in_array( $group, array_diff( $entry->params->newgroups, $entry->params->oldgroups ), true )
172
			) {
173
				$ts = $entry->timestamp;
174
				break;
175
			}
176
		}
177
		return $ts;
178
	}
179
180
	/**
181
	 * Get a list of admins who are in the JSON page but don't have the listed privileges anymore
182
	 *
183
	 * @return string[][]
184
	 */
185
	protected function getExtraGroups(): array {
186
		$extra = [];
187
		foreach ( $this->botList as $name => $groups ) {
188
			$groups = array_diff_key( $groups, array_fill_keys( self::NON_GROUP_KEYS, 1 ) );
189
			if ( !isset( $this->actualList[ $name ] ) ) {
190
				$extra[ $name ] = $groups;
191
			} elseif ( count( $groups ) > count( $this->actualList[ $name ] ) ) {
192
				$extra[ $name ] = array_diff_key( $groups, $this->actualList[ $name ] );
193
			}
194
		}
195
		return $extra;
196
	}
197
198
	/**
199
	 * @param string[] $names
200
	 * @return Generator
201
	 */
202
	private function getRenameEntries( array $names ): Generator {
203
		$titles = array_map( static function ( string $x ): string {
204
			return "Utente:$x";
205
		}, $names );
206
207
		$params = [
208
			'action' => 'query',
209
			'list' => 'logevents',
210
			'leprop' => 'title|details|timestamp',
211
			'letype' => 'renameuser',
212
			'letitle' => implode( '|', $titles ),
213
			'lelimit' => 'max',
214
			// lestart seems to be broken (?)
215
		];
216
217
		return $this->getWiki()->getRequestFactory()->createStandaloneRequest( $params )->executeAsQuery();
218
	}
219
220
	/**
221
	 * Given a list of (old) usernames, check if these people have been renamed recently.
222
	 *
223
	 * @param string[] $names
224
	 * @return string[] [ old_name => new_name ]
225
	 */
226
	protected function getRenamedUsers( array $names ): array {
227
		if ( !$names ) {
228
			return [];
229
		}
230
		$this->getLogger()->info( 'Checking rename for ' . implode( ', ', $names ) );
231
232
		$data = $this->getRenameEntries( $names );
233
		$ret = [];
234
		foreach ( $data as $entry ) {
235
			// 1 month is arbitrary
236
			if ( strtotime( $entry->timestamp ) > strtotime( '-1 month' ) ) {
237
				$par = $entry->params;
238
				$ret[ $par->olduser ] = $par->newuser;
239
			}
240
		}
241
		$this->getLogger()->info( 'Renames found: ' . var_export( $ret, true ) );
242
		return $ret;
243
	}
244
245
	/**
246
	 * Update aliases and overrides for renamed users
247
	 *
248
	 * @param array &$newContent
249
	 * @phan-param array<string,array<string,string|string[]>> $newContent
250
	 * @param string[][] $removed
251
	 */
252
	private function handleRenames( array &$newContent, array $removed ): void {
253
		$renameMap = $this->getRenamedUsers( array_keys( $removed ) );
254
		foreach ( $removed as $oldName => $info ) {
255
			if (
256
				array_key_exists( $oldName, $renameMap ) &&
257
				array_key_exists( $renameMap[$oldName], $newContent )
258
			) {
259
				// This user was renamed! Add this name as alias, if they're still listed
260
				$newName = $renameMap[ $oldName ];
261
				$this->getLogger()->info( "Found rename $oldName -> $newName" );
262
				$aliases = array_unique( array_merge( $newContent[ $newName ]['aliases'] ?? [], [ $oldName ] ) );
263
				$newContent[ $newName ]['aliases'] = $aliases;
264
				// Transfer overrides to the new name.
265
				$overrides = array_diff_key( $info, [ 'override' => 1, 'override-perm' => 1 ] );
266
				$newContent[ $newName ] = array_merge( $newContent[ $newName ], $overrides );
267
			}
268
		}
269
	}
270
271
	/**
272
	 * @param array &$newContent
273
	 * @phan-param array<string,array<string,string|string[]>> $newContent
274
	 * @param string[][] $missing
275
	 * @param string[][] $extra
276
	 * @return string[][] Removed users
277
	 */
278
	private function handleExtraAndMissing(
279
		array &$newContent,
280
		array $missing,
281
		array $extra
282
	): array {
283
		$removed = [];
284
		foreach ( $newContent as $user => $data ) {
285
			if ( isset( $missing[ $user ] ) ) {
286
				$newContent[ $user ] = array_merge( $data, $missing[ $user ] );
287
				unset( $missing[ $user ] );
288
			} elseif ( isset( $extra[ $user ] ) ) {
289
				$newGroups = array_diff_key( $data, $extra[ $user ] );
290
				if ( array_diff_key( $newGroups, array_fill_keys( self::NON_GROUP_KEYS, 1 ) ) ) {
291
					$newContent[ $user ] = $newGroups;
292
				} else {
293
					$removed[$user] = $data;
294
					unset( $newContent[ $user ] );
295
				}
296
			}
297
		}
298
		// Add users which don't have an entry at all
299
		$newContent = array_merge( $newContent, $missing );
300
		return $removed;
301
	}
302
303
	/**
304
	 * Get the new content for the list
305
	 *
306
	 * @param string[][] $missing
307
	 * @param string[][] $extra
308
	 * @return array[]
309
	 */
310
	protected function getNewContent( array $missing, array $extra ): array {
311
		$newContent = $this->botList;
312
313
		$removed = $this->handleExtraAndMissing( $newContent, $missing, $extra );
314
315
		$this->handleRenames( $newContent, $removed );
316
317
		$this->removeOverrides( $newContent );
318
319
		ksort( $newContent, SORT_STRING | SORT_FLAG_CASE );
320
321
		return $newContent;
322
	}
323
324
	/**
325
	 * Remove expired overrides.
326
	 *
327
	 * @param array[] &$newContent
328
	 */
329
	protected function removeOverrides( array &$newContent ): void {
330
		$removed = [];
331
		foreach ( $newContent as $user => $groups ) {
332
			if ( PageBotList::isOverrideExpired( $groups ) ) {
333
				unset( $newContent[ $user ][ 'override' ] );
334
				$removed[] = $user;
335
			}
336
		}
337
338
		if ( $removed ) {
339
			$this->getLogger()->info( 'Removing overrides for users: ' . implode( ', ', $removed ) );
340
		}
341
	}
342
}
343