Passed
Push — master ( 856837...935a55 )
by Daimona
01:47
created

UpdateList::getRenameEntries()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 16
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

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