Passed
Push — master ( 935a55...baa252 )
by Daimona
01:37
created

UpdateList::isOverrideExpired()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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