Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.12% covered (success)
96.12%
446 / 464
79.41% covered (warning)
79.41%
27 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventFactory
96.12% covered (success)
96.12%
446 / 464
79.41% covered (warning)
79.41%
27 / 34
107
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
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 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
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 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 IJobSpecification;
6use MediaWiki\Block\DatabaseBlock;
7use MediaWiki\Block\Restriction\Restriction;
8use MediaWiki\CommentFormatter\CommentFormatter;
9use MediaWiki\Config\ServiceOptions;
10use MediaWiki\Content\IContentHandlerFactory;
11use MediaWiki\Http\Telemetry;
12use MediaWiki\Language\Language;
13use MediaWiki\Linker\LinkTarget;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Page\PageReferenceValue;
16use MediaWiki\Page\WikiPageFactory;
17use MediaWiki\Revision\RevisionRecord;
18use MediaWiki\Revision\RevisionSlots;
19use MediaWiki\Revision\RevisionStore;
20use MediaWiki\Revision\SlotRecord;
21use MediaWiki\Revision\SuppressedDataException;
22use MediaWiki\Title\Title;
23use MediaWiki\Title\TitleFormatter;
24use MediaWiki\User\UserEditTracker;
25use MediaWiki\User\UserFactory;
26use MediaWiki\User\UserGroupManager;
27use MediaWiki\User\UserIdentity;
28use MediaWiki\WikiMap\WikiMap;
29use MWUnknownContentModelException;
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 $e ) {
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 $e ) {
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     * @return array $attrs + meta sub object
330     */
331    public function createEvent(
332        $uri,
333        $schema,
334        $stream,
335        array $attrs,
336        ?string $wiki = null,
337        ?string $dt = null
338    ) {
339        if ( $wiki !== null ) {
340            $wikiRef = WikiMap::getWiki( $wiki );
341            if ( $wikiRef === null ) {
342                $domain = $this->options->get( 'ServerName' );
343            } else {
344                $domain = $wikiRef->getDisplayName();
345            }
346        } else {
347            $domain = $this->options->get( 'ServerName' );
348        }
349
350        $gen = MediaWikiServices::getInstance()->getGlobalIdGenerator();
351        $event = [
352            '$schema' => $schema,
353            'meta' => [
354                'uri'        => $uri,
355                'request_id' => $this->telemetry->getRequestId(),
356                'id'         => $gen->newUUIDv4(),
357                'dt'         => $dt ?? wfTimestamp( TS_ISO_8601 ),
358                'domain'     => $domain,
359                'stream'     => $stream,
360            ],
361        ];
362
363        return $event + $attrs;
364    }
365
366    /**
367     * Creates an event fragment suitable for the fragment/mediawiki/common schema fragment.
368     * @param UserIdentity $user
369     * @return array
370     */
371    public function createMediaWikiCommonAttrs( UserIdentity $user ): array {
372        return [
373            'database'  => $this->dbDomain,
374            'performer' => $this->createPerformerAttrs( $user ),
375        ];
376    }
377
378    /**
379     * Format a timestamp for a date-time attribute in an event.
380     *
381     * @param string $timestamp Timestamp, in a format supported by wfTimestamp()
382     * @return string|bool
383     */
384    public static function createDTAttr( $timestamp ) {
385        return wfTimestamp( TS_ISO_8601, $timestamp );
386    }
387
388    /**
389     * Provides the event attributes common to all CentralNotice events.
390     *
391     * @param string $campaignName The name of the campaign affected.
392     * @param UserIdentity $user The user who performed the action on the campaign.
393     * @param string $summary Change summary provided by the user, or empty string if none
394     *   was provided.
395     * @return array
396     */
397    private function createCommonCentralNoticeAttrs(
398        $campaignName,
399        UserIdentity $user,
400        $summary
401    ) {
402        $attrs = [
403            'database'           => $this->dbDomain,
404            'performer'          => $this->createPerformerAttrs( $user ),
405            'campaign_name'      => $campaignName
406        ];
407
408        if ( $summary ) {
409            $attrs[ 'summary' ] = $summary;
410        }
411
412        return $attrs;
413    }
414
415    /**
416     * Takes an array of CentralNotice campaign settings, as provided by the
417     * CentralNoticeCampaignChange hook, and outputs an array of settings for use in
418     * centralnotice/campaign events.
419     *
420     * @param array $settings
421     * @return array
422     */
423    private static function createCentralNoticeCampignSettingsAttrs( array $settings ) {
424        return [
425            'start_dt'       => self::createDTAttr( $settings[ 'start' ] ),
426            'end_dt'         => self::createDTAttr( $settings[ 'end' ] ),
427            'enabled'        => $settings[ 'enabled' ],
428            'archived'       => $settings[ 'archived' ],
429            'banners'        => $settings[ 'banners' ]
430        ];
431    }
432
433    /**
434     * Given a DatabaseBlock $block, returns an array suitable for use
435     * as a 'blocks' object in the user/blocks-change event schema.
436     *
437     * @param DatabaseBlock $block
438     * @return array
439     */
440    private static function getUserBlocksChangeAttributes( DatabaseBlock $block ) {
441        $blockAttrs = [
442            # Block properties are sometimes a string/int like '0'.
443            # Cast to int then to bool to make sure it is a proper bool.
444            'name'           => (bool)(int)$block->getHideName(),
445            'email'          => (bool)(int)$block->isEmailBlocked(),
446            'user_talk'      => !(bool)(int)$block->isUsertalkEditAllowed(),
447            'account_create' => (bool)(int)$block->isCreateAccountBlocked(),
448            'sitewide'       => $block->isSitewide(),
449        ];
450        $blockAttrs['restrictions'] = array_map( static function ( Restriction $restriction ) {
451            return [
452                'type'  => $restriction::getType(),
453                'value' => $restriction->getValue()
454            ];
455        }, $block->getRestrictions() );
456        if ( $block->getExpiry() != 'infinity' ) {
457            $blockAttrs['expiry_dt'] = self::createDTAttr( $block->getExpiry() );
458        }
459        return $blockAttrs;
460    }
461
462    /**
463     * Creates a cryptographic signature for the event
464     *
465     * @param array &$event the serialized event to sign
466     */
467    private function signEvent( &$event ) {
468        // Sign the event with mediawiki secret key
469        $serialized_event = EventBus::serializeEvents( $event );
470        if ( $serialized_event === null ) {
471            $event['mediawiki_signature'] = null;
472            return;
473        }
474
475        $signature = self::getEventSignature(
476            $serialized_event,
477            $this->options->get( 'SecretKey' )
478        );
479
480        $event['mediawiki_signature'] = $signature;
481    }
482
483    /**
484     * @param string $serialized_event
485     * @param string $secretKey
486     * @return string
487     */
488    public static function getEventSignature( $serialized_event, $secretKey ) {
489        return hash_hmac( 'sha1', $serialized_event, $secretKey );
490    }
491
492    /**
493     * Create a page delete event message
494     * @param string $stream the stream to send an event to
495     * @param UserIdentity|null $user
496     * @param int $id
497     * @param LinkTarget $title
498     * @param bool $is_redirect
499     * @param int $archivedRevisionCount
500     * @param RevisionRecord|null $headRevision
501     * @param string $reason
502     * @return array
503     */
504    public function createPageDeleteEvent(
505        $stream,
506        ?UserIdentity $user,
507        $id,
508        LinkTarget $title,
509        $is_redirect,
510        $archivedRevisionCount,
511        ?RevisionRecord $headRevision,
512        $reason
513    ) {
514        // Create a mediawiki page delete event.
515        $attrs = [
516            // Common MediaWiki entity fields
517            'database'           => $this->dbDomain,
518
519            // page entity fields
520            'page_id'            => $id,
521            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $title ),
522            'page_namespace'     => $title->getNamespace(),
523            'page_is_redirect'   => $is_redirect,
524        ];
525
526        if ( $user ) {
527            $attrs['performer'] = $this->createPerformerAttrs( $user );
528        }
529
530        if ( $headRevision !== null && $headRevision->getId() !== null ) {
531            $attrs['rev_id'] = $headRevision->getId();
532        }
533
534        // page delete specific fields:
535        if ( $archivedRevisionCount !== null ) {
536            $attrs['rev_count'] = $archivedRevisionCount;
537        }
538
539        if ( $reason !== null && strlen( $reason ) ) {
540            $attrs['comment'] = $reason;
541            if ( $this->commentFormatter ) {
542                $attrs['parsedcomment'] = $this->commentFormatter->format( $reason, $title );
543            }
544        }
545
546        return $this->createEvent(
547            $this->getArticleURL( $title ),
548            '/mediawiki/page/delete/1.0.0',
549            $stream,
550            $attrs
551        );
552    }
553
554    /**
555     * Create a page undelete message
556     * @param string $stream the stream to send an event to
557     * @param UserIdentity $performer
558     * @param Title $title
559     * @param string $comment
560     * @param int $oldPageId
561     * @param RevisionRecord $restoredRevision
562     * @return array
563     */
564    public function createPageUndeleteEvent(
565        $stream,
566        UserIdentity $performer,
567        Title $title,
568        $comment,
569        $oldPageId,
570        RevisionRecord $restoredRevision
571    ) {
572        // Create a mediawiki page undelete event.
573        $attrs = [
574            // Common MediaWiki entity fields
575            'database'           => $this->dbDomain,
576            'performer'          => $this->createPerformerAttrs( $performer ),
577
578            // page entity fields
579            'page_id'            => $title->getArticleID(),
580            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $title ),
581            'page_namespace'     => $title->getNamespace(),
582            'page_is_redirect'   => $title->isRedirect(),
583            'rev_id'             => $restoredRevision->getId(),
584        ];
585
586        // If this page had a different id in the archive table,
587        // then save it as the prior_state page_id.  This will
588        // be the page_id that the page had before it was deleted,
589        // which is the same as the page_id that it had while it was
590        // in the archive table.
591        // Usually page_id will be the same, but there are some historical
592        // edge cases where a new page_id is created as part of an undelete.
593        if ( $oldPageId && $oldPageId != $attrs['page_id'] ) {
594            // page undelete specific fields:
595            $attrs['prior_state'] = [
596                'page_id' => $oldPageId,
597            ];
598        }
599
600        if ( $comment !== null && strlen( $comment ) ) {
601            $attrs['comment'] = $comment;
602            if ( $this->commentFormatter ) {
603                $attrs['parsedcomment'] = $this->commentFormatter->format( $comment, $title );
604            }
605        }
606
607        return $this->createEvent(
608            $this->getArticleURL( $title ),
609            '/mediawiki/page/undelete/1.0.0',
610            $stream,
611            $attrs
612        );
613    }
614
615    /**
616     * @param string $stream the stream to send an event to
617     * @param LinkTarget $oldTitle
618     * @param LinkTarget $newTitle
619     * @param RevisionRecord $newRevision
620     * @param UserIdentity $user the user who made a tags change
621     * @param string $reason
622     * @param int $redirectPageId
623     * @return array
624     */
625    public function createPageMoveEvent(
626        $stream,
627        LinkTarget $oldTitle,
628        LinkTarget $newTitle,
629        RevisionRecord $newRevision,
630        UserIdentity $user,
631        $reason,
632        $redirectPageId = 0
633    ) {
634        // TODO: In MCR Content::isRedirect should not be used to derive a redirect directly.
635        $newPageIsRedirect = false;
636        try {
637            $content = $newRevision->getContent( SlotRecord::MAIN );
638            if ( $content !== null ) {
639                $newPageIsRedirect = $content->isRedirect();
640            }
641        } catch ( SuppressedDataException $e ) {
642        }
643
644        $attrs = [
645            // Common MediaWiki entity fields
646            'database'           => $this->dbDomain,
647            'performer'          => $this->createPerformerAttrs( $user ),
648
649            // page entity fields
650            'page_id'            => $newRevision->getPageId(),
651            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $newTitle ),
652            'page_namespace'     => $newTitle->getNamespace(),
653            'page_is_redirect'   => $newPageIsRedirect,
654            'rev_id'             => $newRevision->getId(),
655
656            // page move specific fields:
657            'prior_state'        => [
658                'page_title'     => $this->titleFormatter->getPrefixedDBkey( $oldTitle ),
659                'page_namespace' => $oldTitle->getNamespace(),
660                'rev_id'         => $newRevision->getParentId(),
661            ],
662        ];
663
664        // If a new redirect page was created during this move, then include
665        // some information about it.
666        if ( $redirectPageId ) {
667            $redirectWikiPage = $this->wikiPageFactory->newFromID( $redirectPageId );
668            if ( $redirectWikiPage !== null ) {
669                $attrs['new_redirect_page'] = [
670                    'page_id' => $redirectPageId,
671                    // Redirect pages created as part of a page move
672                    // will have the same title and namespace that
673                    // the target page had before the move.
674                    'page_title' => $attrs['prior_state']['page_title'],
675                    'page_namespace' => $attrs['prior_state']['page_namespace'],
676                    'rev_id' => $redirectWikiPage->getRevisionRecord()->getId()
677                ];
678            }
679        }
680
681        if ( $reason !== null && strlen( $reason ) ) {
682            $attrs['comment'] = $reason;
683            if ( $this->commentFormatter ) {
684                $attrs['parsedcomment'] = $this->commentFormatter->format( $reason, $newTitle );
685            }
686        }
687
688        return $this->createEvent(
689            $this->getArticleURL( $newTitle ),
690            '/mediawiki/page/move/1.0.0',
691            $stream,
692            $attrs
693        );
694    }
695
696    /**
697     * Create an resource change message
698     * @param string $stream the stream to send an event to
699     * @param LinkTarget $title
700     * @param array $tags
701     * @return array
702     */
703    public function createResourceChangeEvent(
704        $stream,
705        LinkTarget $title,
706        array $tags
707    ) {
708        return $this->createEvent(
709            $this->getArticleURL( $title ),
710            '/resource_change/1.0.0',
711            $stream,
712            [ 'tags' => $tags ]
713        );
714    }
715
716    /**
717     * @param string $stream the stream to send an event to
718     * @param RevisionRecord $revisionRecord the revision record affected by the change.
719     * @param array $prevTags an array of previous tags
720     * @param array $addedTags an array of added tags
721     * @param array $removedTags an array of removed tags
722     * @param UserIdentity|null $user the user who made a tags change
723     * @return array
724     */
725    public function createRevisionTagsChangeEvent(
726        $stream,
727        RevisionRecord $revisionRecord,
728        array $prevTags,
729        array $addedTags,
730        array $removedTags,
731        ?UserIdentity $user
732    ) {
733        $attrs = $this->createRevisionRecordAttrs( $revisionRecord, $user );
734
735        $newTags = array_values(
736            array_unique( array_diff( array_merge( $prevTags, $addedTags ), $removedTags ) )
737        );
738        $attrs['tags'] = $newTags;
739        $attrs['prior_state'] = [ 'tags' => $prevTags ];
740
741        return $this->createEvent(
742            $this->getArticleURL( $revisionRecord->getPageAsLinkTarget() ),
743            '/mediawiki/revision/tags-change/1.0.0',
744            $stream,
745            $attrs
746        );
747    }
748
749    /**
750     * @param string $stream the stream to send an event to
751     * @param RevisionRecord $revisionRecord the revision record affected by the change.
752     * @param UserIdentity|null $performer the user who made a tags change
753     * @param array $visibilityChanges
754     * @return array
755     */
756    public function createRevisionVisibilityChangeEvent(
757        $stream,
758        RevisionRecord $revisionRecord,
759        ?UserIdentity $performer,
760        array $visibilityChanges
761    ) {
762        $attrs = $this->createRevisionRecordAttrs(
763            $revisionRecord,
764            $performer
765        );
766        $attrs['visibility'] = self::bitsToVisibilityObject( $visibilityChanges['newBits'] );
767        $attrs['prior_state'] = [
768            'visibility' => self::bitsToVisibilityObject( $visibilityChanges['oldBits'] )
769        ];
770
771        return $this->createEvent(
772            $this->getArticleURL( $revisionRecord->getPageAsLinkTarget() ),
773            '/mediawiki/revision/visibility-change/1.0.0',
774            $stream,
775            $attrs
776        );
777    }
778
779    /**
780     * @param string $stream the stream to send an event to
781     * @param RevisionRecord $revisionRecord the revision record affected by the change.
782     * @return array
783     */
784    public function createRevisionCreateEvent(
785        $stream,
786        RevisionRecord $revisionRecord
787    ) {
788        $attrs = $this->createRevisionRecordAttrs( $revisionRecord, $revisionRecord->getUser() );
789        $attrs['dt'] = self::createDTAttr( $revisionRecord->getTimestamp() );
790        // Only add to revision-create for now
791        $attrs['rev_slots'] = $this->createSlotRecordsAttrs( $revisionRecord->getSlots() );
792        // The parent_revision_id attribute is not required, but when supplied
793        // must have a minimum value of 1, so omit it entirely when there is no
794        // parent revision (i.e. page creation).
795        $parentId = $revisionRecord->getParentId();
796        if ( $parentId !== null && $parentId !== 0 ) {
797            $parentRev = $this->revisionStore->getRevisionById( $parentId );
798            if ( $parentRev !== null ) {
799                $attrs['rev_content_changed'] =
800                    $parentRev->getSha1() !== $revisionRecord->getSha1();
801            }
802        }
803
804        return $this->createEvent(
805            $this->getArticleURL( $revisionRecord->getPageAsLinkTarget() ),
806            '/mediawiki/revision/create/2.0.0',
807            $stream,
808            $attrs
809        );
810    }
811
812    /**
813     * @param string $stream the stream to send an event to
814     * @param Title $title
815     * @param array|null $addedProps
816     * @param array|null $removedProps
817     * @param UserIdentity|null $user the user who made a tags change
818     * @param int|null $revId
819     * @param int $pageId
820     * @return array
821     */
822    public function createPagePropertiesChangeEvent(
823        $stream,
824        Title $title,
825        ?array $addedProps,
826        ?array $removedProps,
827        ?UserIdentity $user,
828        $revId,
829        $pageId
830    ) {
831        // Create a MediaWiki page delete event.
832        $attrs = [
833            // Common MediaWiki entity fields
834            'database'           => $this->dbDomain,
835
836            // page entity fields
837            'page_id'            => $pageId,
838            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $title ),
839            'page_namespace'     => $title->getNamespace(),
840            'page_is_redirect'   => $title->isRedirect(),
841            'rev_id'             => $revId
842        ];
843
844        if ( $user !== null ) {
845            $attrs['performer'] = $this->createPerformerAttrs( $user );
846        }
847
848        if ( $addedProps ) {
849            $attrs['added_properties'] = array_map(
850                [ EventBus::class, 'replaceBinaryValues' ],
851                $addedProps
852            );
853        }
854
855        if ( $removedProps ) {
856            $attrs['removed_properties'] = array_map(
857                [ EventBus::class, 'replaceBinaryValues' ],
858                $removedProps
859            );
860        }
861
862        return $this->createEvent(
863            $this->getArticleURL( $title ),
864            '/mediawiki/page/properties-change/1.0.0',
865            $stream,
866            $attrs
867        );
868    }
869
870    /**
871     * @param string $stream the stream to send an event to
872     * @param Title $title
873     * @param array|null $addedLinks
874     * @param array|null $addedExternalLinks
875     * @param array|null $removedLinks
876     * @param array|null $removedExternalLinks
877     * @param UserIdentity|null $user the user who made a tags change
878     * @param int|null $revId
879     * @param int $pageId
880     * @return array
881     */
882    public function createPageLinksChangeEvent(
883        $stream,
884        Title $title,
885        ?array $addedLinks,
886        ?array $addedExternalLinks,
887        ?array $removedLinks,
888        ?array $removedExternalLinks,
889        ?UserIdentity $user,
890        $revId,
891        $pageId
892    ) {
893        // Create a mediawiki page delete event.
894        $attrs = [
895            // Common MediaWiki entity fields
896            'database'           => $this->dbDomain,
897
898            // page entity fields
899            'page_id'            => $pageId,
900            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $title ),
901            'page_namespace'     => $title->getNamespace(),
902            'page_is_redirect'   => $title->isRedirect(),
903            'rev_id'             => $revId
904        ];
905
906        if ( $user !== null ) {
907            $attrs['performer'] = $this->createPerformerAttrs( $user );
908        }
909
910        /**
911         * Extract URL encoded link and whether it's external
912         * @param PageReferenceValue|String $t External links are strings, internal
913         *   links are PageReferenceValue
914         * @return array
915         */
916        $getLinkData = static function ( $t ) {
917            if ( $t instanceof PageReferenceValue ) {
918                $t = Title::castFromPageReference( $t );
919                $link = $t->getLinkURL();
920                $isExternal = false;
921            } else {
922                $isExternal = true;
923                $link = $t;
924            }
925            return [
926                'link' => wfUrlencode( $link ),
927                'external' => $isExternal
928            ];
929        };
930
931        if ( $addedLinks || $addedExternalLinks ) {
932            $addedLinks = $addedLinks === null ? [] : $addedLinks;
933            $addedExternalLinks = $addedExternalLinks === null ? [] : $addedExternalLinks;
934
935            $addedLinks = array_map(
936                $getLinkData,
937                array_merge( $addedLinks, $addedExternalLinks ) );
938
939            $attrs['added_links'] = $addedLinks;
940        }
941
942        if ( $removedLinks || $removedExternalLinks ) {
943            $removedLinks = $removedLinks === null ? [] : $removedLinks;
944            $removedExternalLinks = $removedExternalLinks === null ? [] : $removedExternalLinks;
945            $removedLinks = array_map(
946                $getLinkData,
947                array_merge( $removedLinks, $removedExternalLinks ) );
948
949            $attrs['removed_links'] = $removedLinks;
950        }
951
952        return $this->createEvent(
953            $this->getArticleURL( $title ),
954            '/mediawiki/page/links-change/1.0.0',
955            $stream,
956            $attrs
957        );
958    }
959
960    /**
961     * Create a user or IP block change event message
962     * @param string $stream the stream to send an event to
963     * @param UserIdentity $user
964     * @param DatabaseBlock $block
965     * @param DatabaseBlock|null $previousBlock
966     * @return array
967     */
968    public function createUserBlockChangeEvent(
969        $stream,
970        UserIdentity $user,
971        DatabaseBlock $block,
972        ?DatabaseBlock $previousBlock
973    ) {
974        $attrs = [
975            // Common MediaWiki entity fields:
976            'database'           => $this->dbDomain,
977            'performer'          => $this->createPerformerAttrs( $user ),
978        ];
979
980        $attrs['comment'] = $block->getReasonComment()->text;
981
982        // user entity fields:
983
984        // Note that, except for null, it is always safe to treat the target
985        // as a string; for UserIdentity objects this will return
986        // UserIdentity::getName()
987        $attrs['user_text'] = $block->getTargetName();
988
989        $blockTargetIdentity = $block->getTargetUserIdentity();
990        // if the $blockTargetIdentity is a UserIdentity, then set user_id.
991        if ( $blockTargetIdentity ) {
992            // set user_id if the target UserIdentity has a user_id
993            if ( $blockTargetIdentity->getId() ) {
994                $attrs['user_id'] = $blockTargetIdentity->getId();
995            }
996
997            // set user_groups, all UserIdentities will have this.
998            $attrs['user_groups'] = $this->userGroupManager->getUserEffectiveGroups( $blockTargetIdentity );
999        }
1000
1001        // blocks-change specific fields:
1002        $attrs['blocks'] = self::getUserBlocksChangeAttributes( $block );
1003
1004        // If we had a prior block settings, emit them as prior_state.blocks.
1005        if ( $previousBlock ) {
1006            $attrs['prior_state'] = [
1007                'blocks' => self::getUserBlocksChangeAttributes( $previousBlock )
1008            ];
1009        }
1010
1011        return $this->createEvent(
1012            $this->getUserPageURL( $block->getTargetName() ),
1013            '/mediawiki/user/blocks-change/1.1.0',
1014            $stream,
1015            $attrs
1016        );
1017    }
1018
1019    /**
1020     * Create a page restrictions change event message
1021     * @param string $stream the stream to send an event to
1022     * @param UserIdentity $user
1023     * @param LinkTarget $title
1024     * @param int $pageId
1025     * @param RevisionRecord|null $revision
1026     * @param bool $is_redirect
1027     * @param string $reason
1028     * @param string[] $protect
1029     * @return array
1030     */
1031    public function createPageRestrictionsChangeEvent(
1032        $stream,
1033        UserIdentity $user,
1034        LinkTarget $title,
1035        $pageId,
1036        ?RevisionRecord $revision,
1037        $is_redirect,
1038        $reason,
1039        array $protect
1040    ) {
1041        // Create a MediaWiki page restrictions change event.
1042        $attrs = [
1043            // Common MediaWiki entity fields
1044            'database'           => $this->dbDomain,
1045            'performer'          => $this->createPerformerAttrs( $user ),
1046
1047            // page entity fields
1048            'page_id'            => $pageId,
1049            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $title ),
1050            'page_namespace'     => $title->getNamespace(),
1051            'page_is_redirect'   => $is_redirect,
1052
1053            // page restrictions change specific fields:
1054            'reason'             => $reason,
1055            'page_restrictions'  => $protect
1056        ];
1057
1058        if ( $revision !== null && $revision->getId() !== null ) {
1059            $attrs['rev_id'] = $revision->getId();
1060        }
1061
1062        return $this->createEvent(
1063            $this->getArticleURL( $title ),
1064            '/mediawiki/page/restrictions-change/1.0.0',
1065            $stream,
1066            $attrs
1067        );
1068    }
1069
1070    /**
1071     * Create a recent change event message
1072     * @param string $stream the stream to send an event to
1073     * @param LinkTarget $title
1074     * @param array $attrs
1075     * @return array
1076     */
1077    public function createRecentChangeEvent( $stream, LinkTarget $title, $attrs ) {
1078        if ( isset( $attrs['comment'] ) && $this->commentFormatter ) {
1079            $attrs['parsedcomment'] = $this->commentFormatter->format( $attrs['comment'], $title );
1080        }
1081
1082        $event = $this->createEvent(
1083            $this->getArticleURL( $title ),
1084            '/mediawiki/recentchange/1.0.0',
1085            $stream,
1086            $attrs
1087        );
1088
1089        // If timestamp exists on the recentchange event (it should),
1090        // then use it as the meta.dt event datetime.
1091        if ( array_key_exists( 'timestamp', $event ) ) {
1092            $event['meta']['dt'] = wfTimestamp( TS_ISO_8601, $event['timestamp'] );
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}