UpdateList::getExtraAdminGroups()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 11
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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