Passed
Push — master ( 0f678f...4d47c7 )
by Daimona
01:37
created

UpdateList::getActualAdmins()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 8
c 2
b 0
f 0
nc 1
nop 0
dl 0
loc 11
rs 10
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->getAdminsList();
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
	 * Given a list of (old) usernames, check if these people have been renamed recently.
186
	 *
187
	 * @param string[] $names
188
	 * @return string[] [ new_name => old_name ]
189
	 */
190
	protected function checkRenamedUsers( array $names ) : array {
191
		if ( !$names ) {
192
			return [];
193
		}
194
195
		$titles = array_map( function ( $x ) {
196
			return "Utente:$x";
197
		}, $names );
198
		$params = [
199
			'action' => 'query',
200
			'list' => 'logevents',
201
			'leprop' => 'title|details|timestamp',
202
			'letype' => 'renameuser',
203
			'letitle' => implode( '|', $titles ),
204
			'lelimit' => 'max',
205
			// lestart seems to be broken, so we check below
206
		];
207
208
		$data = RequestBase::newFromParams( $params )->execute();
209
		$ret = [];
210
		foreach ( $data->query->logevents as $entry ) {
211
			$time = strtotime( $entry->timestamp );
212
			// 1 month is arbitrary
213
			if ( $time > strtotime( '-1 month' ) ) {
214
				$par = $entry->params;
215
				$ret[ $par->newuser ] = $par->olduser;
216
			}
217
		}
218
		return $ret;
219
	}
220
221
	/**
222
	 * Get the new content for the list
223
	 *
224
	 * @param array[] $missing
225
	 * @param array[] $extra
226
	 * @return array[]
227
	 */
228
	protected function getNewContent( array $missing, array $extra ) : array {
229
		$newContent = $this->botList;
230
		$renameMap = $this->checkRenamedUsers( array_keys( $extra ) );
231
		$removed = [];
232
		foreach ( $newContent as $user => $groups ) {
233
			if ( isset( $missing[ $user ] ) ) {
234
				$newContent[ $user ] = array_merge( $groups, $missing[ $user ] );
235
				unset( $missing[ $user ] );
236
			} elseif ( isset( $extra[ $user ] ) ) {
237
				$newGroups = array_diff_key( $groups, $extra[ $user ] );
238
				if ( array_diff_key( $newGroups, array_fill_keys( PageBotList::NON_GROUP_KEYS, 1 ) ) ) {
239
					$newContent[ $user ] = $newGroups;
240
				} else {
241
					$removed[$user] = $newContent[$user];
242
					unset( $newContent[ $user ] );
243
				}
244
			}
245
		}
246
		// Add users which don't have an entry at all
247
		$newContent = array_merge( $newContent, $missing );
248
249
		foreach ( $removed as $oldName => $info ) {
250
			if (
251
				array_key_exists( $oldName, $renameMap ) &&
252
				array_key_exists( $renameMap[$oldName], $newContent )
253
			) {
254
				// This user was renamed! Add this name as alias, if they're still listed
255
				$newName = $renameMap[ $oldName ];
256
				if ( array_key_exists( 'aliases', $newContent[ $newName ] ) ) {
257
					if ( !in_array( $oldName, $newContent[ $newName ]['aliases'] ) ) {
258
						$newContent[ $newName ]['aliases'][] = $oldName;
259
					}
260
				} else {
261
					$newContent[ $newName ]['aliases'] = [ $oldName ];
262
				}
263
				// Transfer overrides to the new name.
264
				$overrides = array_diff_key( $info, [ 'override' => 1, 'override-perm' => 1 ] );
265
				$newContent[ $newName ] = array_merge( $newContent[ $newName ], $overrides );
266
			}
267
		}
268
269
		$newContent = $this->removeOverrides( $newContent );
270
		ksort( $newContent, SORT_STRING | SORT_FLAG_CASE );
271
		return $newContent;
272
	}
273
274
	/**
275
	 * Remove expired overrides. This must happen after the override date has been used AND
276
	 * after the "normal" date has passed. We do it 3 days later to be sure.
277
	 *
278
	 * @param array[] $newContent
279
	 * @return array[]
280
	 */
281
	protected function removeOverrides( array $newContent ) : array {
282
		$removed = [];
283
		foreach ( $newContent as $user => $groups ) {
284
			if ( !isset( $groups['override'] ) ) {
285
				continue;
286
			}
287
288
			$flagTS = PageBotList::getValidFlagTimestamp( $groups );
289
			$usualTS = strtotime( date( 'Y' ) . '-' . date( 'm-d', $flagTS ) );
290
			$overrideTS = \DateTime::createFromFormat( 'd/m/Y', $groups['override'] )->getTimestamp();
291
			$delay = 60 * 60 * 24 * 3;
292
293
			if ( time() > $usualTS + $delay && time() > $overrideTS + $delay ) {
294
				unset( $newContent[ $user ][ 'override' ] );
295
				$removed[] = $user;
296
			}
297
		}
298
299
		if ( $removed ) {
300
			$this->getLogger()->info( 'Removing overrides for users: ' . implode( ', ', $removed ) );
301
		}
302
303
		return $newContent;
304
	}
305
}
306