Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
78.61% |
158 / 201 |
|
40.00% |
4 / 10 |
CRAP | |
0.00% |
0 / 1 |
EventFactory | |
78.61% |
158 / 201 |
|
40.00% |
4 / 10 |
130.07 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
newEvent | |
89.61% |
69 / 77 |
|
0.00% |
0 / 1 |
12.16 | |||
validatePage | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
8 | |||
validateTrackingTool | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
8 | |||
validateTimezone | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
7.04 | |||
validateLocalDates | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
9 | |||
validateMeetingInfo | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
182 | |||
isValidURL | |
88.89% |
16 / 18 |
|
0.00% |
0 / 1 |
10.14 | |||
validateLocation | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
validateParticipantQuestions | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | |
3 | declare( strict_types=1 ); |
4 | |
5 | namespace MediaWiki\Extension\CampaignEvents\Event; |
6 | |
7 | use DateTime; |
8 | use DateTimeZone; |
9 | use Exception; |
10 | use InvalidArgumentException; |
11 | use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsPageFactory; |
12 | use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsPageFormatter; |
13 | use MediaWiki\Extension\CampaignEvents\MWEntity\ICampaignsPage; |
14 | use MediaWiki\Extension\CampaignEvents\MWEntity\InvalidTitleStringException; |
15 | use MediaWiki\Extension\CampaignEvents\MWEntity\PageNotFoundException; |
16 | use MediaWiki\Extension\CampaignEvents\MWEntity\UnexpectedInterwikiException; |
17 | use MediaWiki\Extension\CampaignEvents\MWEntity\UnexpectedSectionAnchorException; |
18 | use MediaWiki\Extension\CampaignEvents\MWEntity\UnexpectedVirtualNamespaceException; |
19 | use MediaWiki\Extension\CampaignEvents\Questions\EventQuestionsRegistry; |
20 | use MediaWiki\Extension\CampaignEvents\Questions\UnknownQuestionException; |
21 | use MediaWiki\Extension\CampaignEvents\TrackingTool\ToolNotFoundException; |
22 | use MediaWiki\Extension\CampaignEvents\TrackingTool\TrackingToolAssociation; |
23 | use MediaWiki\Extension\CampaignEvents\TrackingTool\TrackingToolRegistry; |
24 | use MediaWiki\Utils\MWTimestamp; |
25 | use Message; |
26 | use StatusValue; |
27 | use Wikimedia\RequestTimeout\TimeoutException; |
28 | |
29 | class EventFactory { |
30 | public const SERVICE_NAME = 'CampaignEventsEventFactory'; |
31 | public const VALIDATE_ALL = 0; |
32 | public const VALIDATE_SKIP_DATES_PAST = 1 << 0; |
33 | |
34 | private CampaignsPageFactory $campaignsPageFactory; |
35 | private CampaignsPageFormatter $campaignsPageFormatter; |
36 | private TrackingToolRegistry $trackingToolRegistry; |
37 | private EventQuestionsRegistry $eventQuestionsRegistry; |
38 | |
39 | /** |
40 | * @param CampaignsPageFactory $campaignsPageFactory |
41 | * @param CampaignsPageFormatter $campaignsPageFormatter |
42 | * @param TrackingToolRegistry $trackingToolRegistry |
43 | * @param EventQuestionsRegistry $eventQuestionsRegistry |
44 | */ |
45 | public function __construct( |
46 | CampaignsPageFactory $campaignsPageFactory, |
47 | CampaignsPageFormatter $campaignsPageFormatter, |
48 | TrackingToolRegistry $trackingToolRegistry, |
49 | EventQuestionsRegistry $eventQuestionsRegistry |
50 | ) { |
51 | $this->campaignsPageFactory = $campaignsPageFactory; |
52 | $this->campaignsPageFormatter = $campaignsPageFormatter; |
53 | $this->trackingToolRegistry = $trackingToolRegistry; |
54 | $this->eventQuestionsRegistry = $eventQuestionsRegistry; |
55 | } |
56 | |
57 | /** |
58 | * Creates a new event registration entity, making sure that the given data is valid and formatted as expected. |
59 | * |
60 | * @param int|null $id |
61 | * @param string $pageTitleStr |
62 | * @param string|null $chatURL |
63 | * @param string|null $trackingToolUserID User identifier of a tracking tool |
64 | * @param string|null $trackingToolEventID |
65 | * @param string $status |
66 | * @param string $timezone Can be in any format accepted by DateTimeZone |
67 | * @param string $startLocalTimestamp In the TS_MW format |
68 | * @param string $endLocalTimestamp In the TS_MW format |
69 | * @param string $type |
70 | * @param int $meetingType |
71 | * @param string|null $meetingURL |
72 | * @param string|null $meetingCountry |
73 | * @param string|null $meetingAddress |
74 | * @param string[] $participantQuestionNames |
75 | * @param string|null $creationTimestamp In the TS_MW format |
76 | * @param string|null $lastEditTimestamp In the TS_MW format |
77 | * @param string|null $deletionTimestamp In the TS_MW format |
78 | * @param int $validationFlags |
79 | * @return EventRegistration |
80 | * @throws InvalidEventDataException |
81 | */ |
82 | public function newEvent( |
83 | ?int $id, |
84 | string $pageTitleStr, |
85 | ?string $chatURL, |
86 | ?string $trackingToolUserID, |
87 | ?string $trackingToolEventID, |
88 | string $status, |
89 | string $timezone, |
90 | string $startLocalTimestamp, |
91 | string $endLocalTimestamp, |
92 | string $type, |
93 | int $meetingType, |
94 | ?string $meetingURL, |
95 | ?string $meetingCountry, |
96 | ?string $meetingAddress, |
97 | array $participantQuestionNames, |
98 | ?string $creationTimestamp, |
99 | ?string $lastEditTimestamp, |
100 | ?string $deletionTimestamp, |
101 | int $validationFlags = self::VALIDATE_ALL |
102 | ): EventRegistration { |
103 | $res = StatusValue::newGood(); |
104 | |
105 | if ( $id !== null && $id <= 0 ) { |
106 | $res->error( 'campaignevents-error-invalid-id' ); |
107 | } |
108 | |
109 | $pageStatus = $this->validatePage( $pageTitleStr ); |
110 | $res->merge( $pageStatus ); |
111 | $campaignsPage = $pageStatus->getValue(); |
112 | |
113 | if ( $chatURL !== null ) { |
114 | $chatURL = trim( $chatURL ); |
115 | if ( !$this->isValidURL( $chatURL ) ) { |
116 | $res->error( 'campaignevents-error-invalid-chat-url' ); |
117 | } |
118 | } |
119 | |
120 | $trackingToolStatus = $this->validateTrackingTool( $trackingToolUserID, $trackingToolEventID ); |
121 | $res->merge( $trackingToolStatus ); |
122 | $trackingToolDBID = $trackingToolStatus->getValue(); |
123 | if ( $trackingToolDBID !== null ) { |
124 | $trackingTools = [ |
125 | new TrackingToolAssociation( |
126 | $trackingToolDBID, |
127 | $trackingToolEventID, |
128 | TrackingToolAssociation::SYNC_STATUS_UNKNOWN, |
129 | null |
130 | ) |
131 | ]; |
132 | } else { |
133 | $trackingTools = []; |
134 | } |
135 | |
136 | if ( !in_array( $status, EventRegistration::VALID_STATUSES, true ) ) { |
137 | $res->error( 'campaignevents-error-invalid-status' ); |
138 | } |
139 | |
140 | $timezoneStatus = $this->validateTimezone( $timezone ); |
141 | $res->merge( $timezoneStatus ); |
142 | $timezoneObj = $timezoneStatus->isGood() ? $timezoneStatus->getValue() : null; |
143 | if ( $timezoneObj ) { |
144 | $datesStatus = $this->validateLocalDates( |
145 | $validationFlags, |
146 | $timezoneObj, |
147 | $startLocalTimestamp, |
148 | $endLocalTimestamp |
149 | ); |
150 | $res->merge( $datesStatus ); |
151 | } |
152 | |
153 | if ( !in_array( $type, EventRegistration::VALID_TYPES, true ) ) { |
154 | $res->error( 'campaignevents-error-invalid-type' ); |
155 | } |
156 | |
157 | $res->merge( $this->validateMeetingInfo( $meetingType, $meetingURL, $meetingCountry, $meetingAddress ) ); |
158 | |
159 | $questionsStatus = $this->validateParticipantQuestions( $participantQuestionNames ); |
160 | $res->merge( $questionsStatus ); |
161 | $questionIDs = $questionsStatus->getValue(); |
162 | |
163 | $creationTSUnix = wfTimestampOrNull( TS_UNIX, $creationTimestamp ); |
164 | $lastEditTSUnix = wfTimestampOrNull( TS_UNIX, $lastEditTimestamp ); |
165 | $deletionTSUnix = wfTimestampOrNull( TS_UNIX, $deletionTimestamp ); |
166 | // Creation, last edit, and deletion timestamp don't need user-facing validation since it's not the |
167 | // user setting them. |
168 | $invalidTimestamps = array_filter( |
169 | [ 'creation' => $creationTSUnix, 'lastedit' => $lastEditTSUnix, 'deletion' => $deletionTSUnix ], |
170 | static function ( $ts ): bool { |
171 | return $ts === false; |
172 | } |
173 | ); |
174 | if ( $invalidTimestamps ) { |
175 | throw new InvalidArgumentException( |
176 | "Invalid timestamps: " . implode( ', ', array_keys( $invalidTimestamps ) ) |
177 | ); |
178 | } |
179 | |
180 | if ( !$res->isGood() ) { |
181 | throw new InvalidEventDataException( $res ); |
182 | } |
183 | |
184 | /** @var ICampaignsPage $campaignsPage */ |
185 | '@phan-var ICampaignsPage $campaignsPage'; |
186 | |
187 | return new EventRegistration( |
188 | $id, |
189 | $this->campaignsPageFormatter->getText( $campaignsPage ), |
190 | $campaignsPage, |
191 | $chatURL, |
192 | $trackingTools, |
193 | $status, |
194 | $timezoneObj, |
195 | $startLocalTimestamp, |
196 | $endLocalTimestamp, |
197 | $type, |
198 | $meetingType, |
199 | $meetingURL, |
200 | $meetingCountry, |
201 | $meetingAddress, |
202 | $questionIDs, |
203 | $creationTSUnix, |
204 | $lastEditTSUnix, |
205 | $deletionTSUnix |
206 | ); |
207 | } |
208 | |
209 | /** |
210 | * Validates the page title provided as a string. |
211 | * |
212 | * @param string $pageTitleStr |
213 | * @return StatusValue Fatal if invalid, good otherwise and with an ICampaignsPage as value. |
214 | */ |
215 | private function validatePage( string $pageTitleStr ): StatusValue { |
216 | $pageTitleStr = trim( $pageTitleStr ); |
217 | if ( $pageTitleStr === '' ) { |
218 | return StatusValue::newFatal( 'campaignevents-error-empty-title' ); |
219 | } |
220 | |
221 | try { |
222 | $campaignsPage = $this->campaignsPageFactory->newLocalExistingPageFromString( $pageTitleStr ); |
223 | } catch ( InvalidTitleStringException $e ) { |
224 | // TODO: Ideally we wouldn't need wfMessage here. |
225 | return StatusValue::newFatal( |
226 | 'campaignevents-error-invalid-title', |
227 | wfMessage( $e->getErrorMsgKey(), $e->getErrorMsgParams() ) |
228 | ); |
229 | } catch ( UnexpectedInterwikiException $_ ) { |
230 | return StatusValue::newFatal( 'campaignevents-error-invalid-title-interwiki' ); |
231 | } catch ( UnexpectedVirtualNamespaceException $_ ) { |
232 | return StatusValue::newFatal( 'campaignevents-error-page-not-event-namespace' ); |
233 | } catch ( UnexpectedSectionAnchorException $_ ) { |
234 | return StatusValue::newFatal( 'campaignevents-error-page-with-section' ); |
235 | } catch ( PageNotFoundException $_ ) { |
236 | return StatusValue::newFatal( 'campaignevents-error-page-not-found' ); |
237 | } |
238 | |
239 | if ( $campaignsPage->getNamespace() !== NS_EVENT ) { |
240 | return StatusValue::newFatal( 'campaignevents-error-page-not-event-namespace' ); |
241 | } |
242 | |
243 | return StatusValue::newGood( $campaignsPage ); |
244 | } |
245 | |
246 | /** |
247 | * @param string|null $trackingToolUserID |
248 | * @param string|null $trackingToolEventID |
249 | * @return StatusValue If good, has the tracking tool DB ID as value, or null if no tool was specified. |
250 | */ |
251 | private function validateTrackingTool( ?string $trackingToolUserID, ?string $trackingToolEventID ): StatusValue { |
252 | if ( $trackingToolUserID === null || $trackingToolEventID === null ) { |
253 | if ( $trackingToolUserID !== null && $trackingToolEventID === null ) { |
254 | return StatusValue::newFatal( 'campaignevents-error-trackingtool-without-eventid' ); |
255 | } |
256 | if ( $trackingToolUserID === null && $trackingToolEventID !== null ) { |
257 | return StatusValue::newFatal( 'campaignevents-error-trackingtool-eventid-without-toolid' ); |
258 | } |
259 | return StatusValue::newGood( null ); |
260 | } |
261 | |
262 | try { |
263 | return StatusValue::newGood( |
264 | $this->trackingToolRegistry->newFromUserIdentifier( $trackingToolUserID )->getDBID() |
265 | ); |
266 | } catch ( ToolNotFoundException $_ ) { |
267 | return StatusValue::newFatal( 'campaignevents-error-invalid-trackingtool' ); |
268 | } |
269 | } |
270 | |
271 | /** |
272 | * @param string $timezone |
273 | * @return StatusValue If good, has the corresponding DateTimeZone object as value. |
274 | */ |
275 | private function validateTimezone( string $timezone ): StatusValue { |
276 | if ( preg_match( '/^[+-]/', $timezone ) ) { |
277 | $matches = []; |
278 | if ( !preg_match( '/^[+-](\d\d):(\d\d)$/', $timezone, $matches ) ) { |
279 | // Work around bug in PHP: strings starting with + and - do not throw an exception in PHP < 8, |
280 | // see https://3v4l.org/SE0oA. This also rejects offsets where the hours or the minutes have more |
281 | // than 3 digits, which PHP accepts but then does not handle properly; the exact meaning of "not handle |
282 | // properly" depends on the PHP version, see https://github.com/php/php-src/issues/9763#issue-1411450292 |
283 | return StatusValue::newFatal( 'campaignevents-error-invalid-timezone' ); |
284 | } |
285 | // Work around another PHP bug: if the hours are < 100 but hours + 60 * miutes >= 100*60, it will truncate |
286 | // the input and add a null byte that makes it unusable, see https://github.com/php/php-src/issues/9763 |
287 | if ( $matches[1] === '99' && (int)$matches[2] >= 60 ) { |
288 | return StatusValue::newFatal( 'campaignevents-error-invalid-timezone' ); |
289 | } |
290 | } |
291 | try { |
292 | return StatusValue::newGood( new DateTimeZone( $timezone ) ); |
293 | } catch ( TimeoutException $e ) { |
294 | throw $e; |
295 | } catch ( Exception $e ) { |
296 | // PHP throws a generic Exception, but we don't want to catch excimer timeouts. |
297 | // Again, thanks PHP for making error handling so convoluted here. |
298 | // See https://github.com/php/php-src/issues/9784 |
299 | return StatusValue::newFatal( 'campaignevents-error-invalid-timezone' ); |
300 | } |
301 | } |
302 | |
303 | /** |
304 | * @param int $validationFlags |
305 | * @param DateTimeZone $timezone |
306 | * @param string $start |
307 | * @param string $end |
308 | * @return StatusValue |
309 | */ |
310 | private function validateLocalDates( |
311 | int $validationFlags, |
312 | DateTimeZone $timezone, |
313 | string $start, |
314 | string $end |
315 | ): StatusValue { |
316 | $res = StatusValue::newGood(); |
317 | |
318 | $startTSUnix = null; |
319 | $endTSUnix = null; |
320 | $startAndEndValid = true; |
321 | if ( $start === '' ) { |
322 | $startAndEndValid = false; |
323 | $res->error( 'campaignevents-error-empty-start' ); |
324 | } elseif ( MWTimestamp::convert( TS_MW, $start ) !== $start ) { |
325 | // This accounts for both the timestamp being invalid and it not being TS_MW. |
326 | $startAndEndValid = false; |
327 | $res->error( 'campaignevents-error-invalid-start' ); |
328 | } else { |
329 | $startTSUnix = ( new DateTime( $start, $timezone ) )->getTimestamp(); |
330 | if ( |
331 | !( $validationFlags & self::VALIDATE_SKIP_DATES_PAST ) && $startTSUnix < MWTimestamp::time() |
332 | ) { |
333 | $res->error( 'campaignevents-error-start-past' ); |
334 | } |
335 | } |
336 | |
337 | if ( $end === '' ) { |
338 | $startAndEndValid = false; |
339 | $res->error( 'campaignevents-error-empty-end' ); |
340 | } elseif ( MWTimestamp::convert( TS_MW, $end ) !== $end ) { |
341 | // This accounts for both the timestamp being invalid and it not being TS_MW. |
342 | $startAndEndValid = false; |
343 | $res->error( 'campaignevents-error-invalid-end' ); |
344 | } else { |
345 | $endTSUnix = ( new DateTime( $end, $timezone ) )->getTimestamp(); |
346 | } |
347 | |
348 | if ( $startAndEndValid && $startTSUnix > $endTSUnix ) { |
349 | $res->error( 'campaignevents-error-start-after-end' ); |
350 | } |
351 | |
352 | return $res; |
353 | } |
354 | |
355 | /** |
356 | * @param int $meetingType |
357 | * @param string|null &$meetingURL |
358 | * @param string|null &$meetingCountry |
359 | * @param string|null &$meetingAddress |
360 | * @return StatusValue |
361 | */ |
362 | private function validateMeetingInfo( |
363 | int $meetingType, |
364 | ?string &$meetingURL, |
365 | ?string &$meetingCountry, |
366 | ?string &$meetingAddress |
367 | ): StatusValue { |
368 | $res = StatusValue::newGood(); |
369 | |
370 | if ( !in_array( $meetingType, EventRegistration::VALID_MEETING_TYPES, true ) ) { |
371 | $res->error( 'campaignevents-error-no-meeting-type' ); |
372 | // Don't bother checking the rest. |
373 | return $res; |
374 | } |
375 | |
376 | if ( $meetingType & EventRegistration::MEETING_TYPE_ONLINE ) { |
377 | if ( $meetingURL !== null ) { |
378 | $meetingURL = trim( $meetingURL ); |
379 | if ( !$this->isValidURL( $meetingURL ) ) { |
380 | $res->error( 'campaignevents-error-invalid-meeting-url' ); |
381 | } |
382 | } |
383 | } elseif ( $meetingURL !== null ) { |
384 | $res->error( 'campaignevents-error-meeting-url-not-online' ); |
385 | } |
386 | |
387 | if ( $meetingType & EventRegistration::MEETING_TYPE_IN_PERSON ) { |
388 | if ( $meetingCountry !== null ) { |
389 | $meetingCountry = trim( $meetingCountry ); |
390 | } |
391 | if ( $meetingAddress !== null ) { |
392 | $meetingAddress = trim( $meetingAddress ); |
393 | } |
394 | if ( $meetingCountry !== null && $meetingAddress !== null ) { |
395 | $res->merge( $this->validateLocation( $meetingCountry, $meetingAddress ) ); |
396 | } |
397 | } elseif ( $meetingCountry !== null || $meetingAddress !== null ) { |
398 | $res->error( 'campaignevents-error-countryoraddress-not-in-person' ); |
399 | } |
400 | return $res; |
401 | } |
402 | |
403 | /** |
404 | * @param string $data |
405 | * @return bool |
406 | */ |
407 | private function isValidURL( string $data ): bool { |
408 | // TODO There's a lot of space for improvement here, e.g., expand the list of allowed protocols, and |
409 | // possibly avoid having to do all the normalization and checks ourselves. |
410 | $allowedSchemes = [ 'http', 'https' ]; |
411 | |
412 | // Add the HTTPS protocol explicitly, since FILTER_VALIDATE_URL wants a scheme. |
413 | $urlToCheck = preg_match( '/^\/\/.*/', $data ) ? "https:$data" : $data; |
414 | $urlParts = parse_url( $urlToCheck ); |
415 | |
416 | // Validate scheme, host presence, and allowed schemes |
417 | if ( |
418 | $urlParts === false || !isset( $urlParts[ 'scheme' ] ) || |
419 | !isset( $urlParts[ 'host' ] ) || |
420 | !in_array( strtolower( $urlParts[ 'scheme' ] ), $allowedSchemes, true ) |
421 | ) { |
422 | return false; |
423 | } |
424 | |
425 | // Convert URL host from IDN to ASCII (Punycode) |
426 | $hostASCII = idn_to_ascii( $urlParts[ 'host' ], IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ); |
427 | if ( $hostASCII === false ) { |
428 | return false; |
429 | } |
430 | |
431 | // Rebuild the URL with the ASCII host for validation |
432 | $urlToCheckASCII = $urlParts[ 'scheme' ] . "://" . $hostASCII; |
433 | if ( isset( $urlParts[ 'path' ] ) ) { |
434 | $urlToCheckASCII .= '/' . urlencode( $urlParts[ 'path' ] ); |
435 | } |
436 | if ( isset( $urlParts[ 'query' ] ) ) { |
437 | $urlToCheckASCII .= '?' . urlencode( $urlParts[ 'query' ] ); |
438 | } |
439 | if ( isset( $urlParts[ 'fragment' ] ) ) { |
440 | $urlToCheckASCII .= '#' . urlencode( $urlParts[ 'fragment' ] ); |
441 | } |
442 | |
443 | return filter_var( $urlToCheckASCII, FILTER_VALIDATE_URL ) !== false; |
444 | } |
445 | |
446 | /** |
447 | * @param string $country |
448 | * @param string $address |
449 | * @return StatusValue |
450 | */ |
451 | private function validateLocation( string $country, string $address ): StatusValue { |
452 | $res = StatusValue::newGood(); |
453 | if ( $country === '' ) { |
454 | $res->error( 'campaignevents-error-invalid-country' ); |
455 | } |
456 | if ( $address === '' ) { |
457 | $res->error( 'campaignevents-error-invalid-address' ); |
458 | } |
459 | return $res; |
460 | } |
461 | |
462 | /** |
463 | * @param string[] $questionNames |
464 | * @return StatusValue Whose value is an array of the corresponding question DB IDs. |
465 | */ |
466 | private function validateParticipantQuestions( array $questionNames ): StatusValue { |
467 | $questionIDs = []; |
468 | $invalidNames = []; |
469 | foreach ( $questionNames as $name ) { |
470 | try { |
471 | $questionIDs[] = $this->eventQuestionsRegistry->nameToDBID( $name ); |
472 | } catch ( UnknownQuestionException $_ ) { |
473 | $invalidNames[] = $name; |
474 | } |
475 | } |
476 | $ret = StatusValue::newGood( $questionIDs ); |
477 | if ( $invalidNames ) { |
478 | $ret->fatal( 'campaignevents-error-invalid-question names', Message::listParam( $invalidNames ) ); |
479 | } |
480 | return $ret; |
481 | } |
482 | |
483 | } |