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