Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.10% covered (success)
96.10%
443 / 461
79.41% covered (warning)
79.41%
27 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventFactory
96.10% covered (success)
96.10%
443 / 461
79.41% covered (warning)
79.41%
27 / 34
106
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 setCommentFormatter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserPageURL
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 bitsToVisibilityObject
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 isHidden
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getArticleURL
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 createRevisionRecordAttrs
91.18% covered (success)
91.18%
31 / 34
0.00% covered (danger)
0.00%
0 / 1
12.10
 createSlotRecordsAttrs
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 createPerformerAttrs
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 createEvent
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
3.00
 createMediaWikiCommonAttrs
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 createDTAttr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createCommonCentralNoticeAttrs
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 createCentralNoticeCampignSettingsAttrs
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getUserBlocksChangeAttributes
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 signEvent
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
2.04
 getEventSignature
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createPageDeleteEvent
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
8
 createPageUndeleteEvent
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 createPageMoveEvent
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
8
 createResourceChangeEvent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 createRevisionTagsChangeEvent
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 createRevisionVisibilityChangeEvent
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 createRevisionCreateEvent
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 createPagePropertiesChangeEvent
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 createPageLinksChangeEvent
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
11
 createUserBlockChangeEvent
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
4
 createPageRestrictionsChangeEvent
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 createRecentChangeEvent
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 createJobEvent
78.57% covered (warning)
78.57%
22 / 28
0.00% covered (danger)
0.00%
0 / 1
7.48
 createCentralNoticeCampaignCreateEvent
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 createCentralNoticeCampaignChangeEvent
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 createCentralNoticeCampaignDeleteEvent
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 createRecommendationCreateEvent
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\EventBus;
4
5use MediaWiki\Block\DatabaseBlock;
6use MediaWiki\Block\Restriction\Restriction;
7use MediaWiki\CommentFormatter\CommentFormatter;
8use MediaWiki\Config\ServiceOptions;
9use MediaWiki\Content\IContentHandlerFactory;
10use MediaWiki\Exception\MWUnknownContentModelException;
11use MediaWiki\Http\Telemetry;
12use MediaWiki\JobQueue\IJobSpecification;
13use MediaWiki\Language\Language;
14use MediaWiki\Linker\LinkTarget;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Page\PageReferenceValue;
17use MediaWiki\Page\WikiPageFactory;
18use MediaWiki\Revision\RevisionRecord;
19use MediaWiki\Revision\RevisionSlots;
20use MediaWiki\Revision\RevisionStore;
21use MediaWiki\Revision\SlotRecord;
22use MediaWiki\Revision\SuppressedDataException;
23use MediaWiki\Title\Title;
24use MediaWiki\Title\TitleFormatter;
25use MediaWiki\User\UserEditTracker;
26use MediaWiki\User\UserFactory;
27use MediaWiki\User\UserGroupManager;
28use MediaWiki\User\UserIdentity;
29use MediaWiki\WikiMap\WikiMap;
30use Psr\Log\LoggerInterface;
31
32/**
33 * Used to create events of particular types.
34 *
35 * @deprecated since EventBus 0.5.0. Use EventSerializer and specific Serializer instances instead.
36 */
37class EventFactory {
38
39    public const CONSTRUCTOR_OPTIONS = [
40        'ArticlePath',
41        'CanonicalServer',
42        'ServerName',
43        'SecretKey'
44    ];
45
46    /** @var ServiceOptions */
47    private $options;
48
49    /** @var Language */
50    private $contentLanguage;
51
52    /** @var TitleFormatter */
53    private $titleFormatter;
54
55    /** @var RevisionStore */
56    private $revisionStore;
57
58    /** @var UserGroupManager */
59    private $userGroupManager;
60
61    /** @var UserEditTracker */
62    private $userEditTracker;
63
64    /** @var UserFactory */
65    private $userFactory;
66
67    /** @var string */
68    private $dbDomain;
69
70    /** @var WikiPageFactory */
71    private $wikiPageFactory;
72
73    /**
74     * @var CommentFormatter|null Will be null unless set by caller with setCommentFormatter().
75     */
76    private ?CommentFormatter $commentFormatter = null;
77
78    /** @var IContentHandlerFactory */
79    private $contentHandlerFactory;
80
81    /** @var LoggerInterface */
82    private $logger;
83
84    private Telemetry $telemetry;
85
86    /**
87     * @param ServiceOptions $serviceOptions
88     * @param string $dbDomain
89     * @param Language $contentLanguage
90     * @param RevisionStore $revisionStore
91     * @param TitleFormatter $titleFormatter
92     * @param UserGroupManager $userGroupManager
93     * @param UserEditTracker $userEditTracker
94     * @param WikiPageFactory $wikiPageFactory
95     * @param UserFactory $userFactory
96     * @param IContentHandlerFactory $contentHandlerFactory
97     * @param LoggerInterface $logger
98     * @param Telemetry $telemetry
99     */
100    public function __construct(
101        ServiceOptions $serviceOptions,
102        string $dbDomain,
103        Language $contentLanguage,
104        RevisionStore $revisionStore,
105        TitleFormatter $titleFormatter,
106        UserGroupManager $userGroupManager,
107        UserEditTracker $userEditTracker,
108        WikiPageFactory $wikiPageFactory,
109        UserFactory $userFactory,
110        IContentHandlerFactory $contentHandlerFactory,
111        LoggerInterface $logger,
112        Telemetry $telemetry
113    ) {
114        $serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
115        $this->options = $serviceOptions;
116        $this->dbDomain = $dbDomain;
117        $this->contentLanguage = $contentLanguage;
118        $this->titleFormatter = $titleFormatter;
119        $this->revisionStore = $revisionStore;
120        $this->userGroupManager = $userGroupManager;
121        $this->userEditTracker = $userEditTracker;
122        $this->wikiPageFactory = $wikiPageFactory;
123        $this->userFactory = $userFactory;
124        $this->contentHandlerFactory = $contentHandlerFactory;
125        $this->logger = $logger;
126        $this->telemetry = $telemetry;
127    }
128
129    /**
130     * Inject a CommentFormatter for EventFactory's use. Only needed if you need comment_html populated (T327065).
131     * @param CommentFormatter $commentFormatter
132     * @return void
133     */
134    public function setCommentFormatter( CommentFormatter $commentFormatter ): void {
135        $this->commentFormatter = $commentFormatter;
136    }
137
138    /**
139     * Creates a full user page path
140     *
141     * @param string $userName
142     * @return string
143     */
144    private function getUserPageURL( $userName ) {
145        $prefixedUserURL = $this->contentLanguage->getNsText( NS_USER ) . ':' . $userName;
146        $encodedUserURL = wfUrlencode( strtr( $prefixedUserURL, ' ', '_' ) );
147        // The ArticlePath contains '$1' string where the article title should appear.
148        return $this->options->get( 'CanonicalServer' ) .
149            str_replace( '$1', $encodedUserURL, $this->options->get( 'ArticlePath' ) );
150    }
151
152    /**
153     * Converts a revision visibility hidden bitfield to an array with keys
154     * of each of the possible visibility settings name mapped to a boolean.
155     *
156     * @param int $bits revision visibility bitfield
157     * @return array
158     */
159    private static function bitsToVisibilityObject( $bits ) {
160        return [
161            'text'    => !self::isHidden( $bits, RevisionRecord::DELETED_TEXT ),
162            'user'    => !self::isHidden( $bits, RevisionRecord::DELETED_USER ),
163            'comment' => !self::isHidden( $bits, RevisionRecord::DELETED_COMMENT ),
164        ];
165    }
166
167    /**
168     * Checks if RevisionRecord::DELETED_* field is set in the $hiddenBits
169     *
170     * @param int $hiddenBits revision visibility bitfield
171     * @param int $field RevisionRecord::DELETED_* field to check
172     * @return bool
173     */
174    private static function isHidden( $hiddenBits, $field ) {
175        return ( $hiddenBits & $field ) == $field;
176    }
177
178    /**
179     * Creates a full article path
180     *
181     * @param LinkTarget $target article title object
182     * @return string
183     */
184    private function getArticleURL( $target ) {
185        $titleURL = wfUrlencode( $this->titleFormatter->getPrefixedDBkey( $target ) );
186        // The ArticlePath contains '$1' string where the article title should appear.
187        return $this->options->get( 'CanonicalServer' ) .
188            str_replace( '$1', $titleURL, $this->options->get( 'ArticlePath' ) );
189    }
190
191    /**
192     * Given a RevisionRecord $revision, returns an array suitable for
193     * use in mediawiki/revision entity schemas.
194     *
195     * @param RevisionRecord $revision
196     * @param UserIdentity|null $performer
197     * @return array
198     */
199    private function createRevisionRecordAttrs(
200        RevisionRecord $revision,
201        ?UserIdentity $performer = null
202    ) {
203        $linkTarget = $revision->getPageAsLinkTarget();
204        $attrs = [
205            // Common MediaWiki entity fields
206            'database'           => $this->dbDomain,
207
208            // revision entity fields
209            'page_id'            => $revision->getPageId(),
210            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $linkTarget ),
211            'page_namespace'     => $linkTarget->getNamespace(),
212            'rev_id'             => $revision->getId(),
213            'rev_timestamp'      => self::createDTAttr( $revision->getTimestamp() ),
214            'rev_sha1'           => $revision->getSha1(),
215            'rev_minor_edit'     => $revision->isMinor(),
216            'rev_len'            => $revision->getSize(),
217        ];
218
219        $attrs['rev_content_model'] = $contentModel = $revision->getSlot( SlotRecord::MAIN )->getModel();
220
221        $contentFormat = $revision->getSlot( SlotRecord::MAIN )->getFormat();
222        if ( $contentFormat === null ) {
223            try {
224                $contentFormat = $this->contentHandlerFactory->getContentHandler( $contentModel )->getDefaultFormat();
225            } catch ( MWUnknownContentModelException ) {
226                // Ignore, the `rev_content_format` is not required.
227            }
228        }
229        if ( $contentFormat !== null ) {
230            $attrs['rev_content_format'] = $contentFormat;
231        }
232
233        if ( $performer !== null ) {
234            $attrs['performer'] = $this->createPerformerAttrs( $performer );
235        }
236
237        // It is possible that the $revision object does not have any content
238        // at the time of RevisionRecordInserted.  This might happen during
239        // a page restore, if the revision 'created' during the restore
240        // has its content hidden.
241        // TODO: In MCR Content::isRedirect should not be used to derive a redirect directly.
242        try {
243            $content = $revision->getContent( SlotRecord::MAIN );
244            if ( $content !== null ) {
245                $attrs['page_is_redirect'] = $content->isRedirect();
246            } else {
247                $attrs['page_is_redirect'] = false;
248            }
249        } catch ( SuppressedDataException ) {
250            $attrs['page_is_redirect'] = false;
251        }
252
253        if ( $revision->getComment() !== null && strlen( $revision->getComment()->text ) ) {
254            $attrs['comment'] = $revision->getComment()->text;
255            if ( $this->commentFormatter ) {
256                $attrs['parsedcomment'] = $this->commentFormatter->format( $revision->getComment()->text );
257            }
258        }
259
260        // The rev_parent_id attribute is not required, but when supplied
261        // must have a minimum value of 1, so omit it entirely when there is no
262        // parent revision (i.e. page creation).
263        if ( $revision->getParentId() !== null && $revision->getParentId() > 0 ) {
264            $attrs['rev_parent_id'] = $revision->getParentId();
265        }
266
267        return $attrs;
268    }
269
270    /**
271     * @param RevisionSlots $slots
272     * @return array
273     */
274    private function createSlotRecordsAttrs( RevisionSlots $slots ): array {
275        $attrs = [];
276        foreach ( $slots->getSlots() as $slotRecord ) {
277            $slotAttr = [
278                'rev_slot_content_model' => $slotRecord->getModel(),
279                'rev_slot_sha1' => $slotRecord->getSha1(),
280                'rev_slot_size' => $slotRecord->getSize()
281            ];
282            if ( $slotRecord->hasOrigin() ) {
283                // unclear if necessary to guard against missing origin in this context but since it
284                // might fail on unsaved content we are better safe than sorry
285                $slotAttr['rev_slot_origin_rev_id'] = $slotRecord->getOrigin();
286            }
287            $attrs[$slotRecord->getRole()] = $slotAttr;
288        }
289        return $attrs;
290    }
291
292    /**
293     * Given a UserIdentity $user, returns an array suitable for
294     * use as the performer JSON object in various MediaWiki
295     * entity schemas.
296     * @param UserIdentity $user
297     * @return array
298     */
299    private function createPerformerAttrs( UserIdentity $user ) {
300        $legacyUser = $this->userFactory->newFromUserIdentity( $user );
301        $performerAttrs = [
302            'user_text'   => $user->getName(),
303            'user_groups' => $this->userGroupManager->getUserEffectiveGroups( $user ),
304            'user_is_bot' => $user->isRegistered() && $legacyUser->isBot(),
305        ];
306        if ( $user->getId() ) {
307            $performerAttrs['user_id'] = $user->getId();
308        }
309        if ( $legacyUser->getRegistration() ) {
310            $performerAttrs['user_registration_dt'] =
311                self::createDTAttr( $legacyUser->getRegistration() );
312        }
313        if ( $user->isRegistered() ) {
314            $performerAttrs['user_edit_count'] = $this->userEditTracker->getUserEditCount( $user );
315        }
316
317        return $performerAttrs;
318    }
319
320    /**
321     * Adds a meta subobject to $attrs based on uri and topic and returns it.
322     *
323     * @param string $uri
324     * @param string $schema
325     * @param string $stream
326     * @param array $attrs
327     * @param string|null $wiki wikiId if provided
328     * @param string|null $dt
329     *        Deprecated. This parameter is unused.
330     *        https://phabricator.wikimedia.org/T267648
331     * @return array $attrs + meta sub object
332     */
333    public function createEvent(
334        $uri,
335        $schema,
336        $stream,
337        array $attrs,
338        ?string $wiki = null,
339        ?string $dt = null
340    ) {
341        // TODO: we should not rely on ServerName, but instead
342        // always rely on either ProperPageIdentity getWikiId or WikiMap::getCurrentWikiId.
343        // This has been done for newer Serializers, but not for these older EventFactory
344        // based events.
345        // See also https://phabricator.wikimedia.org/T388825
346        if ( $wiki !== null ) {
347            $wikiRef = WikiMap::getWiki( $wiki );
348            if ( $wikiRef === null ) {
349                $domain = $this->options->get( 'ServerName' );
350            } else {
351                $domain = $wikiRef->getDisplayName();
352            }
353        } else {
354            $domain = $this->options->get( 'ServerName' );
355        }
356
357        $gen = MediaWikiServices::getInstance()->getGlobalIdGenerator();
358        $event = [
359            '$schema' => $schema,
360            'meta' => [
361                'uri'        => $uri,
362                'request_id' => $this->telemetry->getRequestId(),
363                'id'         => $gen->newUUIDv4(),
364                'domain'     => $domain,
365                'stream'     => $stream,
366            ],
367        ];
368
369        return $event + $attrs;
370    }
371
372    /**
373     * Creates an event fragment suitable for the fragment/mediawiki/common schema fragment.
374     * @param UserIdentity $user
375     * @return array
376     */
377    public function createMediaWikiCommonAttrs( UserIdentity $user ): array {
378        return [
379            'database'  => $this->dbDomain,
380            'performer' => $this->createPerformerAttrs( $user ),
381        ];
382    }
383
384    /**
385     * Format a timestamp for a date-time attribute in an event.
386     *
387     * @param string $timestamp Timestamp, in a format supported by wfTimestamp()
388     * @return string|bool
389     */
390    public static function createDTAttr( $timestamp ) {
391        return wfTimestamp( TS_ISO_8601, $timestamp );
392    }
393
394    /**
395     * Provides the event attributes common to all CentralNotice events.
396     *
397     * @param string $campaignName The name of the campaign affected.
398     * @param UserIdentity $user The user who performed the action on the campaign.
399     * @param string $summary Change summary provided by the user, or empty string if none
400     *   was provided.
401     * @return array
402     */
403    private function createCommonCentralNoticeAttrs(
404        $campaignName,
405        UserIdentity $user,
406        $summary
407    ) {
408        $attrs = [
409            'database'           => $this->dbDomain,
410            'performer'          => $this->createPerformerAttrs( $user ),
411            'campaign_name'      => $campaignName
412        ];
413
414        if ( $summary ) {
415            $attrs[ 'summary' ] = $summary;
416        }
417
418        return $attrs;
419    }
420
421    /**
422     * Takes an array of CentralNotice campaign settings, as provided by the
423     * CentralNoticeCampaignChange hook, and outputs an array of settings for use in
424     * centralnotice/campaign events.
425     *
426     * @param array $settings
427     * @return array
428     */
429    private static function createCentralNoticeCampignSettingsAttrs( array $settings ) {
430        return [
431            'start_dt'       => self::createDTAttr( $settings[ 'start' ] ),
432            'end_dt'         => self::createDTAttr( $settings[ 'end' ] ),
433            'enabled'        => $settings[ 'enabled' ],
434            'archived'       => $settings[ 'archived' ],
435            'banners'        => $settings[ 'banners' ]
436        ];
437    }
438
439    /**
440     * Given a DatabaseBlock $block, returns an array suitable for use
441     * as a 'blocks' object in the user/blocks-change event schema.
442     *
443     * @param DatabaseBlock $block
444     * @return array
445     */
446    private static function getUserBlocksChangeAttributes( DatabaseBlock $block ) {
447        $blockAttrs = [
448            # Block properties are sometimes a string/int like '0'.
449            # Cast to int then to bool to make sure it is a proper bool.
450            'name'           => (bool)(int)$block->getHideName(),
451            'email'          => (bool)(int)$block->isEmailBlocked(),
452            'user_talk'      => !(bool)(int)$block->isUsertalkEditAllowed(),
453            'account_create' => (bool)(int)$block->isCreateAccountBlocked(),
454            'sitewide'       => $block->isSitewide(),
455        ];
456        $blockAttrs['restrictions'] = array_map( static function ( Restriction $restriction ) {
457            return [
458                'type'  => $restriction::getType(),
459                'value' => $restriction->getValue()
460            ];
461        }, $block->getRestrictions() );
462        if ( $block->getExpiry() != 'infinity' ) {
463            $blockAttrs['expiry_dt'] = self::createDTAttr( $block->getExpiry() );
464        }
465        return $blockAttrs;
466    }
467
468    /**
469     * Creates a cryptographic signature for the event
470     *
471     * @param array &$event the serialized event to sign
472     */
473    private function signEvent( &$event ) {
474        // Sign the event with mediawiki secret key
475        $serialized_event = EventBus::serializeEvents( $event );
476        if ( $serialized_event === null ) {
477            $event['mediawiki_signature'] = null;
478            return;
479        }
480
481        $signature = self::getEventSignature(
482            $serialized_event,
483            $this->options->get( 'SecretKey' )
484        );
485
486        $event['mediawiki_signature'] = $signature;
487    }
488
489    /**
490     * @param string $serialized_event
491     * @param string $secretKey
492     * @return string
493     */
494    public static function getEventSignature( $serialized_event, $secretKey ) {
495        return hash_hmac( 'sha1', $serialized_event, $secretKey );
496    }
497
498    /**
499     * Create a page delete event message
500     * @param string $stream the stream to send an event to
501     * @param UserIdentity|null $user
502     * @param int $id
503     * @param LinkTarget $title
504     * @param bool $is_redirect
505     * @param int $archivedRevisionCount
506     * @param RevisionRecord|null $headRevision
507     * @param string $reason
508     * @return array
509     */
510    public function createPageDeleteEvent(
511        $stream,
512        ?UserIdentity $user,
513        $id,
514        LinkTarget $title,
515        $is_redirect,
516        $archivedRevisionCount,
517        ?RevisionRecord $headRevision,
518        $reason
519    ) {
520        // Create a mediawiki page delete event.
521        $attrs = [
522            // Common MediaWiki entity fields
523            'database'           => $this->dbDomain,
524
525            // page entity fields
526            'page_id'            => $id,
527            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $title ),
528            'page_namespace'     => $title->getNamespace(),
529            'page_is_redirect'   => $is_redirect,
530        ];
531
532        if ( $user ) {
533            $attrs['performer'] = $this->createPerformerAttrs( $user );
534        }
535
536        if ( $headRevision !== null && $headRevision->getId() !== null ) {
537            $attrs['rev_id'] = $headRevision->getId();
538        }
539
540        // page delete specific fields:
541        if ( $archivedRevisionCount !== null ) {
542            $attrs['rev_count'] = $archivedRevisionCount;
543        }
544
545        if ( $reason !== null && strlen( $reason ) ) {
546            $attrs['comment'] = $reason;
547            if ( $this->commentFormatter ) {
548                $attrs['parsedcomment'] = $this->commentFormatter->format( $reason, $title );
549            }
550        }
551
552        return $this->createEvent(
553            $this->getArticleURL( $title ),
554            '/mediawiki/page/delete/1.0.0',
555            $stream,
556            $attrs
557        );
558    }
559
560    /**
561     * Create a page undelete message
562     * @param string $stream the stream to send an event to
563     * @param UserIdentity $performer
564     * @param Title $title
565     * @param string $comment
566     * @param int $oldPageId
567     * @param RevisionRecord $restoredRevision
568     * @return array
569     */
570    public function createPageUndeleteEvent(
571        $stream,
572        UserIdentity $performer,
573        Title $title,
574        $comment,
575        $oldPageId,
576        RevisionRecord $restoredRevision
577    ) {
578        // Create a mediawiki page undelete event.
579        $attrs = [
580            // Common MediaWiki entity fields
581            'database'           => $this->dbDomain,
582            'performer'          => $this->createPerformerAttrs( $performer ),
583
584            // page entity fields
585            'page_id'            => $title->getArticleID(),
586            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $title ),
587            'page_namespace'     => $title->getNamespace(),
588            'page_is_redirect'   => $title->isRedirect(),
589            'rev_id'             => $restoredRevision->getId(),
590        ];
591
592        // If this page had a different id in the archive table,
593        // then save it as the prior_state page_id.  This will
594        // be the page_id that the page had before it was deleted,
595        // which is the same as the page_id that it had while it was
596        // in the archive table.
597        // Usually page_id will be the same, but there are some historical
598        // edge cases where a new page_id is created as part of an undelete.
599        if ( $oldPageId && $oldPageId != $attrs['page_id'] ) {
600            // page undelete specific fields:
601            $attrs['prior_state'] = [
602                'page_id' => $oldPageId,
603            ];
604        }
605
606        if ( $comment !== null && strlen( $comment ) ) {
607            $attrs['comment'] = $comment;
608            if ( $this->commentFormatter ) {
609                $attrs['parsedcomment'] = $this->commentFormatter->format( $comment, $title );
610            }
611        }
612
613        return $this->createEvent(
614            $this->getArticleURL( $title ),
615            '/mediawiki/page/undelete/1.0.0',
616            $stream,
617            $attrs
618        );
619    }
620
621    /**
622     * @param string $stream the stream to send an event to
623     * @param LinkTarget $oldTitle
624     * @param LinkTarget $newTitle
625     * @param RevisionRecord $newRevision
626     * @param UserIdentity $user the user who made a tags change
627     * @param string $reason
628     * @param int $redirectPageId
629     * @return array
630     */
631    public function createPageMoveEvent(
632        $stream,
633        LinkTarget $oldTitle,
634        LinkTarget $newTitle,
635        RevisionRecord $newRevision,
636        UserIdentity $user,
637        $reason,
638        $redirectPageId = 0
639    ) {
640        // TODO: In MCR Content::isRedirect should not be used to derive a redirect directly.
641        $newPageIsRedirect = false;
642        try {
643            $content = $newRevision->getContent( SlotRecord::MAIN );
644            if ( $content !== null ) {
645                $newPageIsRedirect = $content->isRedirect();
646            }
647        } catch ( SuppressedDataException ) {
648        }
649
650        $attrs = [
651            // Common MediaWiki entity fields
652            'database'           => $this->dbDomain,
653            'performer'          => $this->createPerformerAttrs( $user ),
654
655            // page entity fields
656            'page_id'            => $newRevision->getPageId(),
657            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $newTitle ),
658            'page_namespace'     => $newTitle->getNamespace(),
659            'page_is_redirect'   => $newPageIsRedirect,
660            'rev_id'             => $newRevision->getId(),
661
662            // page move specific fields:
663            'prior_state'        => [
664                'page_title'     => $this->titleFormatter->getPrefixedDBkey( $oldTitle ),
665                'page_namespace' => $oldTitle->getNamespace(),
666                'rev_id'         => $newRevision->getParentId(),
667            ],
668        ];
669
670        // If a new redirect page was created during this move, then include
671        // some information about it.
672        if ( $redirectPageId ) {
673            $redirectWikiPage = $this->wikiPageFactory->newFromID( $redirectPageId );
674            if ( $redirectWikiPage !== null ) {
675                $attrs['new_redirect_page'] = [
676                    'page_id' => $redirectPageId,
677                    // Redirect pages created as part of a page move
678                    // will have the same title and namespace that
679                    // the target page had before the move.
680                    'page_title' => $attrs['prior_state']['page_title'],
681                    'page_namespace' => $attrs['prior_state']['page_namespace'],
682                    'rev_id' => $redirectWikiPage->getRevisionRecord()->getId()
683                ];
684            }
685        }
686
687        if ( $reason !== null && strlen( $reason ) ) {
688            $attrs['comment'] = $reason;
689            if ( $this->commentFormatter ) {
690                $attrs['parsedcomment'] = $this->commentFormatter->format( $reason, $newTitle );
691            }
692        }
693
694        return $this->createEvent(
695            $this->getArticleURL( $newTitle ),
696            '/mediawiki/page/move/1.0.0',
697            $stream,
698            $attrs
699        );
700    }
701
702    /**
703     * Create an resource change message
704     * @param string $stream the stream to send an event to
705     * @param LinkTarget $title
706     * @param array $tags
707     * @return array
708     */
709    public function createResourceChangeEvent(
710        $stream,
711        LinkTarget $title,
712        array $tags
713    ) {
714        return $this->createEvent(
715            $this->getArticleURL( $title ),
716            '/resource_change/1.0.0',
717            $stream,
718            [ 'tags' => $tags ]
719        );
720    }
721
722    /**
723     * @param string $stream the stream to send an event to
724     * @param RevisionRecord $revisionRecord the revision record affected by the change.
725     * @param array $prevTags an array of previous tags
726     * @param array $addedTags an array of added tags
727     * @param array $removedTags an array of removed tags
728     * @param UserIdentity|null $user the user who made a tags change
729     * @return array
730     */
731    public function createRevisionTagsChangeEvent(
732        $stream,
733        RevisionRecord $revisionRecord,
734        array $prevTags,
735        array $addedTags,
736        array $removedTags,
737        ?UserIdentity $user
738    ) {
739        $attrs = $this->createRevisionRecordAttrs( $revisionRecord, $user );
740
741        $newTags = array_values(
742            array_unique( array_diff( array_merge( $prevTags, $addedTags ), $removedTags ) )
743        );
744        $attrs['tags'] = $newTags;
745        $attrs['prior_state'] = [ 'tags' => $prevTags ];
746
747        return $this->createEvent(
748            $this->getArticleURL( $revisionRecord->getPageAsLinkTarget() ),
749            '/mediawiki/revision/tags-change/1.0.0',
750            $stream,
751            $attrs
752        );
753    }
754
755    /**
756     * @param string $stream the stream to send an event to
757     * @param RevisionRecord $revisionRecord the revision record affected by the change.
758     * @param UserIdentity|null $performer the user who made a tags change
759     * @param array $visibilityChanges
760     * @return array
761     */
762    public function createRevisionVisibilityChangeEvent(
763        $stream,
764        RevisionRecord $revisionRecord,
765        ?UserIdentity $performer,
766        array $visibilityChanges
767    ) {
768        $attrs = $this->createRevisionRecordAttrs(
769            $revisionRecord,
770            $performer
771        );
772        $attrs['visibility'] = self::bitsToVisibilityObject( $visibilityChanges['newBits'] );
773        $attrs['prior_state'] = [
774            'visibility' => self::bitsToVisibilityObject( $visibilityChanges['oldBits'] )
775        ];
776
777        return $this->createEvent(
778            $this->getArticleURL( $revisionRecord->getPageAsLinkTarget() ),
779            '/mediawiki/revision/visibility-change/1.0.0',
780            $stream,
781            $attrs
782        );
783    }
784
785    /**
786     * @param string $stream the stream to send an event to
787     * @param RevisionRecord $revisionRecord the revision record affected by the change.
788     * @return array
789     */
790    public function createRevisionCreateEvent(
791        $stream,
792        RevisionRecord $revisionRecord
793    ) {
794        $attrs = $this->createRevisionRecordAttrs( $revisionRecord, $revisionRecord->getUser() );
795        $attrs['dt'] = self::createDTAttr( $revisionRecord->getTimestamp() );
796        // Only add to revision-create for now
797        $attrs['rev_slots'] = $this->createSlotRecordsAttrs( $revisionRecord->getSlots() );
798        // The parent_revision_id attribute is not required, but when supplied
799        // must have a minimum value of 1, so omit it entirely when there is no
800        // parent revision (i.e. page creation).
801        $parentId = $revisionRecord->getParentId();
802        if ( $parentId !== null && $parentId !== 0 ) {
803            $parentRev = $this->revisionStore->getRevisionById( $parentId );
804            if ( $parentRev !== null ) {
805                $attrs['rev_content_changed'] =
806                    $parentRev->getSha1() !== $revisionRecord->getSha1();
807            }
808        }
809
810        return $this->createEvent(
811            $this->getArticleURL( $revisionRecord->getPageAsLinkTarget() ),
812            '/mediawiki/revision/create/2.0.0',
813            $stream,
814            $attrs
815        );
816    }
817
818    /**
819     * @param string $stream the stream to send an event to
820     * @param Title $title
821     * @param array|null $addedProps
822     * @param array|null $removedProps
823     * @param UserIdentity|null $user the user who made a tags change
824     * @param int|null $revId
825     * @param int $pageId
826     * @return array
827     */
828    public function createPagePropertiesChangeEvent(
829        $stream,
830        Title $title,
831        ?array $addedProps,
832        ?array $removedProps,
833        ?UserIdentity $user,
834        $revId,
835        $pageId
836    ) {
837        // Create a MediaWiki page delete event.
838        $attrs = [
839            // Common MediaWiki entity fields
840            'database'           => $this->dbDomain,
841
842            // page entity fields
843            'page_id'            => $pageId,
844            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $title ),
845            'page_namespace'     => $title->getNamespace(),
846            'page_is_redirect'   => $title->isRedirect(),
847            'rev_id'             => $revId
848        ];
849
850        if ( $user !== null ) {
851            $attrs['performer'] = $this->createPerformerAttrs( $user );
852        }
853
854        if ( $addedProps ) {
855            $attrs['added_properties'] = array_map(
856                [ EventBus::class, 'replaceBinaryValues' ],
857                $addedProps
858            );
859        }
860
861        if ( $removedProps ) {
862            $attrs['removed_properties'] = array_map(
863                [ EventBus::class, 'replaceBinaryValues' ],
864                $removedProps
865            );
866        }
867
868        return $this->createEvent(
869            $this->getArticleURL( $title ),
870            '/mediawiki/page/properties-change/1.0.0',
871            $stream,
872            $attrs
873        );
874    }
875
876    /**
877     * @param string $stream the stream to send an event to
878     * @param Title $title
879     * @param array|null $addedLinks
880     * @param array|null $addedExternalLinks
881     * @param array|null $removedLinks
882     * @param array|null $removedExternalLinks
883     * @param UserIdentity|null $user the user who made a tags change
884     * @param int|null $revId
885     * @param int $pageId
886     * @return array
887     */
888    public function createPageLinksChangeEvent(
889        $stream,
890        Title $title,
891        ?array $addedLinks,
892        ?array $addedExternalLinks,
893        ?array $removedLinks,
894        ?array $removedExternalLinks,
895        ?UserIdentity $user,
896        $revId,
897        $pageId
898    ) {
899        // Create a mediawiki page delete event.
900        $attrs = [
901            // Common MediaWiki entity fields
902            'database'           => $this->dbDomain,
903
904            // page entity fields
905            'page_id'            => $pageId,
906            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $title ),
907            'page_namespace'     => $title->getNamespace(),
908            'page_is_redirect'   => $title->isRedirect(),
909            'rev_id'             => $revId
910        ];
911
912        if ( $user !== null ) {
913            $attrs['performer'] = $this->createPerformerAttrs( $user );
914        }
915
916        /**
917         * Extract URL encoded link and whether it's external
918         * @param PageReferenceValue|String $t External links are strings, internal
919         *   links are PageReferenceValue
920         * @return array
921         */
922        $getLinkData = static function ( $t ) {
923            if ( $t instanceof PageReferenceValue ) {
924                $t = Title::castFromPageReference( $t );
925                $link = $t->getLinkURL();
926                $isExternal = false;
927            } else {
928                $isExternal = true;
929                $link = $t;
930            }
931            return [
932                'link' => wfUrlencode( $link ),
933                'external' => $isExternal
934            ];
935        };
936
937        if ( $addedLinks || $addedExternalLinks ) {
938            $addedLinks = $addedLinks === null ? [] : $addedLinks;
939            $addedExternalLinks = $addedExternalLinks === null ? [] : $addedExternalLinks;
940
941            $addedLinks = array_map(
942                $getLinkData,
943                array_merge( $addedLinks, $addedExternalLinks ) );
944
945            $attrs['added_links'] = $addedLinks;
946        }
947
948        if ( $removedLinks || $removedExternalLinks ) {
949            $removedLinks = $removedLinks === null ? [] : $removedLinks;
950            $removedExternalLinks = $removedExternalLinks === null ? [] : $removedExternalLinks;
951            $removedLinks = array_map(
952                $getLinkData,
953                array_merge( $removedLinks, $removedExternalLinks ) );
954
955            $attrs['removed_links'] = $removedLinks;
956        }
957
958        return $this->createEvent(
959            $this->getArticleURL( $title ),
960            '/mediawiki/page/links-change/1.0.0',
961            $stream,
962            $attrs
963        );
964    }
965
966    /**
967     * Create a user or IP block change event message
968     * @param string $stream the stream to send an event to
969     * @param UserIdentity $user
970     * @param DatabaseBlock $block
971     * @param DatabaseBlock|null $previousBlock
972     * @return array
973     */
974    public function createUserBlockChangeEvent(
975        $stream,
976        UserIdentity $user,
977        DatabaseBlock $block,
978        ?DatabaseBlock $previousBlock
979    ) {
980        $attrs = [
981            // Common MediaWiki entity fields:
982            'database'           => $this->dbDomain,
983            'performer'          => $this->createPerformerAttrs( $user ),
984        ];
985
986        $attrs['comment'] = $block->getReasonComment()->text;
987
988        // user entity fields:
989
990        // Note that, except for null, it is always safe to treat the target
991        // as a string; for UserIdentity objects this will return
992        // UserIdentity::getName()
993        $attrs['user_text'] = $block->getTargetName();
994
995        $blockTargetIdentity = $block->getTargetUserIdentity();
996        // if the $blockTargetIdentity is a UserIdentity, then set user_id.
997        if ( $blockTargetIdentity ) {
998            // set user_id if the target UserIdentity has a user_id
999            if ( $blockTargetIdentity->getId() ) {
1000                $attrs['user_id'] = $blockTargetIdentity->getId();
1001            }
1002
1003            // set user_groups, all UserIdentities will have this.
1004            $attrs['user_groups'] = $this->userGroupManager->getUserEffectiveGroups( $blockTargetIdentity );
1005        }
1006
1007        // blocks-change specific fields:
1008        $attrs['blocks'] = self::getUserBlocksChangeAttributes( $block );
1009
1010        // If we had a prior block settings, emit them as prior_state.blocks.
1011        if ( $previousBlock ) {
1012            $attrs['prior_state'] = [
1013                'blocks' => self::getUserBlocksChangeAttributes( $previousBlock )
1014            ];
1015        }
1016
1017        return $this->createEvent(
1018            $this->getUserPageURL( $block->getTargetName() ),
1019            '/mediawiki/user/blocks-change/1.1.0',
1020            $stream,
1021            $attrs
1022        );
1023    }
1024
1025    /**
1026     * Create a page restrictions change event message
1027     * @param string $stream the stream to send an event to
1028     * @param UserIdentity $user
1029     * @param LinkTarget $title
1030     * @param int $pageId
1031     * @param RevisionRecord|null $revision
1032     * @param bool $is_redirect
1033     * @param string $reason
1034     * @param string[] $protect
1035     * @return array
1036     */
1037    public function createPageRestrictionsChangeEvent(
1038        $stream,
1039        UserIdentity $user,
1040        LinkTarget $title,
1041        $pageId,
1042        ?RevisionRecord $revision,
1043        $is_redirect,
1044        $reason,
1045        array $protect
1046    ) {
1047        // Create a MediaWiki page restrictions change event.
1048        $attrs = [
1049            // Common MediaWiki entity fields
1050            'database'           => $this->dbDomain,
1051            'performer'          => $this->createPerformerAttrs( $user ),
1052
1053            // page entity fields
1054            'page_id'            => $pageId,
1055            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $title ),
1056            'page_namespace'     => $title->getNamespace(),
1057            'page_is_redirect'   => $is_redirect,
1058
1059            // page restrictions change specific fields:
1060            'reason'             => $reason,
1061            'page_restrictions'  => $protect
1062        ];
1063
1064        if ( $revision !== null && $revision->getId() !== null ) {
1065            $attrs['rev_id'] = $revision->getId();
1066        }
1067
1068        return $this->createEvent(
1069            $this->getArticleURL( $title ),
1070            '/mediawiki/page/restrictions-change/2.0.0',
1071            $stream,
1072            $attrs
1073        );
1074    }
1075
1076    /**
1077     * Create a recent change event message
1078     * @param string $stream the stream to send an event to
1079     * @param LinkTarget $title
1080     * @param array $attrs
1081     * @return array
1082     */
1083    public function createRecentChangeEvent( $stream, LinkTarget $title, $attrs ) {
1084        if ( isset( $attrs['comment'] ) && $this->commentFormatter ) {
1085            $attrs['parsedcomment'] = $this->commentFormatter->format( $attrs['comment'], $title );
1086        }
1087
1088        $event = $this->createEvent(
1089            $this->getArticleURL( $title ),
1090            '/mediawiki/recentchange/1.0.0',
1091            $stream,
1092            $attrs
1093        );
1094
1095        return $event;
1096    }
1097
1098    /**
1099     * Creates an event representing a job specification.
1100     * @param string $stream the stream to send an event to
1101     * @param string $wiki wikiId
1102     * @param IJobSpecification $job the job specification
1103     * @return array
1104     */
1105    public function createJobEvent(
1106        $stream,
1107        $wiki,
1108        IJobSpecification $job
1109    ) {
1110        $attrs = [
1111            'database' => $wiki ?: $this->dbDomain,
1112            'type' => $job->getType(),
1113        ];
1114
1115        if ( $job->getReleaseTimestamp() !== null ) {
1116            $attrs['delay_until'] = wfTimestamp( TS_ISO_8601, $job->getReleaseTimestamp() );
1117        }
1118
1119        if ( $job->ignoreDuplicates() ) {
1120            $attrs['sha1'] = sha1( serialize( $job->getDeduplicationInfo() ) );
1121        }
1122
1123        $params = $job->getParams();
1124
1125        if ( isset( $params['rootJobTimestamp'] ) && isset( $params['rootJobSignature'] ) ) {
1126            $attrs['root_event'] = [
1127                'signature' => $params['rootJobSignature'],
1128                'dt'        => wfTimestamp( TS_ISO_8601, $params['rootJobTimestamp'] )
1129            ];
1130        }
1131
1132        $attrs['params'] = $params;
1133
1134        // Deprecated, not used. To be removed from the schema. (T221368)
1135        $url = 'https://placeholder.invalid/wiki/Special:Badtitle';
1136
1137        $event = $this->createEvent(
1138            $url,
1139            '/mediawiki/job/1.0.0',
1140            $stream,
1141            $attrs,
1142            $wiki
1143        );
1144
1145        // If the job provides a requestId - use it, otherwise try to get one ourselves
1146        if ( isset( $event['params']['requestId'] ) ) {
1147            $event['meta']['request_id'] = $event['params']['requestId'];
1148        } else {
1149            $event['meta']['request_id'] = $this->telemetry->getRequestId();
1150        }
1151
1152        $this->signEvent( $event );
1153
1154        return $event;
1155    }
1156
1157    /**
1158     * @param string $stream
1159     * @param string $campaignName
1160     * @param UserIdentity $user
1161     * @param array $settings
1162     * @param string $summary
1163     * @param string $campaignUrl
1164     * @return array
1165     */
1166    public function createCentralNoticeCampaignCreateEvent(
1167        $stream,
1168        $campaignName,
1169        UserIdentity $user,
1170        array $settings,
1171        $summary,
1172        $campaignUrl
1173    ) {
1174        $attrs = $this->createCommonCentralNoticeAttrs( $campaignName, $user, $summary );
1175        $attrs += self::createCentralNoticeCampignSettingsAttrs( $settings );
1176
1177        return $this->createEvent(
1178            $campaignUrl,
1179            '/mediawiki/centralnotice/campaign/create/1.0.0',
1180            $stream,
1181            $attrs
1182        );
1183    }
1184
1185    /**
1186     * @param string $stream
1187     * @param string $campaignName
1188     * @param UserIdentity $user
1189     * @param array $settings
1190     * @param array $priorState
1191     * @param string $summary
1192     * @param string $campaignUrl
1193     * @return array
1194     */
1195    public function createCentralNoticeCampaignChangeEvent(
1196        $stream,
1197        $campaignName,
1198        UserIdentity $user,
1199        array $settings,
1200        array $priorState,
1201        $summary,
1202        $campaignUrl
1203    ) {
1204        $attrs = $this->createCommonCentralNoticeAttrs( $campaignName, $user, $summary );
1205
1206        $attrs += self::createCentralNoticeCampignSettingsAttrs( $settings );
1207        $attrs[ 'prior_state' ] =
1208            $priorState ? self::createCentralNoticeCampignSettingsAttrs( $priorState ) : [];
1209
1210        return $this->createEvent(
1211            $campaignUrl,
1212            '/mediawiki/centralnotice/campaign/change/1.0.0',
1213            $stream,
1214            $attrs
1215        );
1216    }
1217
1218    /**
1219     * @param string $stream
1220     * @param string $campaignName
1221     * @param UserIdentity $user
1222     * @param array $priorState
1223     * @param string $summary
1224     * @param string $campaignUrl
1225     * @return array
1226     */
1227    public function createCentralNoticeCampaignDeleteEvent(
1228        $stream,
1229        $campaignName,
1230        UserIdentity $user,
1231        array $priorState,
1232        $summary,
1233        $campaignUrl
1234    ) {
1235        $attrs = $this->createCommonCentralNoticeAttrs( $campaignName, $user, $summary );
1236        // As of 2019-06-07 the $beginSettings are *never* set in \Campaign::removeCampaignByName()
1237        // in the CentralNotice extension where the CentralNoticeCampaignChange hook is fired!
1238        $attrs[ 'prior_state' ] =
1239            $priorState ? self::createCentralNoticeCampignSettingsAttrs( $priorState ) : [];
1240
1241        return $this->createEvent(
1242            $campaignUrl,
1243            '/mediawiki/centralnotice/campaign/delete/1.0.0',
1244            $stream,
1245            $attrs
1246        );
1247    }
1248
1249    /**
1250     * Creates a mediawiki/revision/recommendation-create event. Called by other extensions (for
1251     * now, just GrowthExperiments) whenever they generate recommendations; the event will be used
1252     * to keep the search infrastructure informed about available recommendations.
1253     * @param string $stream
1254     * @param string $recommendationType A type, such as 'link' or 'image'.
1255     * @param RevisionRecord $revisionRecord The revision which the recommendation is based on.
1256     * @return array
1257     */
1258    public function createRecommendationCreateEvent(
1259        $stream,
1260        $recommendationType,
1261        RevisionRecord $revisionRecord
1262    ) {
1263        $attrs = $this->createRevisionRecordAttrs( $revisionRecord, $revisionRecord->getUser() );
1264        $attrs['recommendation_type'] = $recommendationType;
1265
1266        return $this->createEvent(
1267            $this->getArticleURL( $revisionRecord->getPageAsLinkTarget() ),
1268            '/mediawiki/revision/recommendation-create/1.0.0',
1269            $stream,
1270            $attrs
1271        );
1272    }
1273}