Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.61% covered (warning)
78.61%
158 / 201
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventFactory
78.61% covered (warning)
78.61%
158 / 201
40.00% covered (danger)
40.00%
4 / 10
130.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 newEvent
89.61% covered (warning)
89.61%
69 / 77
0.00% covered (danger)
0.00%
0 / 1
12.16
 validatePage
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
8
 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\Questions\EventQuestionsRegistry;
20use MediaWiki\Extension\CampaignEvents\Questions\UnknownQuestionException;
21use MediaWiki\Extension\CampaignEvents\TrackingTool\ToolNotFoundException;
22use MediaWiki\Extension\CampaignEvents\TrackingTool\TrackingToolAssociation;
23use MediaWiki\Extension\CampaignEvents\TrackingTool\TrackingToolRegistry;
24use MediaWiki\Utils\MWTimestamp;
25use Message;
26use StatusValue;
27use Wikimedia\RequestTimeout\TimeoutException;
28
29class 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}