Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.11% covered (success)
96.11%
445 / 463
79.41% covered (warning)
79.41%
27 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventFactory
96.11% covered (success)
96.11%
445 / 463
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%
18 / 18
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 Language;
7use MediaWiki\Block\DatabaseBlock;
8use MediaWiki\Block\Restriction\Restriction;
9use MediaWiki\CommentFormatter\CommentFormatter;
10use MediaWiki\Config\ServiceOptions;
11use MediaWiki\Content\IContentHandlerFactory;
12use MediaWiki\Http\Telemetry;
13use MediaWiki\Linker\LinkTarget;
14use MediaWiki\Page\PageReferenceValue;
15use MediaWiki\Page\WikiPageFactory;
16use MediaWiki\Revision\RevisionRecord;
17use MediaWiki\Revision\RevisionSlots;
18use MediaWiki\Revision\RevisionStore;
19use MediaWiki\Revision\SlotRecord;
20use MediaWiki\Revision\SuppressedDataException;
21use MediaWiki\Title\Title;
22use MediaWiki\Title\TitleFormatter;
23use MediaWiki\User\UserEditTracker;
24use MediaWiki\User\UserFactory;
25use MediaWiki\User\UserGroupManager;
26use MediaWiki\User\UserIdentity;
27use MediaWiki\WikiMap\WikiMap;
28use MWUnknownContentModelException;
29use Psr\Log\LoggerInterface;
30use UIDGenerator;
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 *
37 */
38class EventFactory {
39
40    public const CONSTRUCTOR_OPTIONS = [
41        'ArticlePath',
42        'CanonicalServer',
43        'ServerName',
44        'SecretKey'
45    ];
46
47    /** @var ServiceOptions */
48    private $options;
49
50    /** @var Language */
51    private $contentLanguage;
52
53    /** @var TitleFormatter */
54    private $titleFormatter;
55
56    /** @var RevisionStore */
57    private $revisionStore;
58
59    /** @var UserGroupManager */
60    private $userGroupManager;
61
62    /** @var UserEditTracker */
63    private $userEditTracker;
64
65    /** @var UserFactory */
66    private $userFactory;
67
68    /** @var string */
69    private $dbDomain;
70
71    /** @var WikiPageFactory */
72    private $wikiPageFactory;
73
74    /**
75     * @var CommentFormatter|null Will be null unless set by caller with setCommentFormatter().
76     */
77    private ?CommentFormatter $commentFormatter = null;
78
79    /** @var IContentHandlerFactory */
80    private $contentHandlerFactory;
81
82    /** @var LoggerInterface */
83    private $logger;
84
85    private Telemetry $telemetry;
86
87    /**
88     * @param ServiceOptions $serviceOptions
89     * @param string $dbDomain
90     * @param Language $contentLanguage
91     * @param RevisionStore $revisionStore
92     * @param TitleFormatter $titleFormatter
93     * @param UserGroupManager $userGroupManager
94     * @param UserEditTracker $userEditTracker
95     * @param WikiPageFactory $wikiPageFactory
96     * @param UserFactory $userFactory
97     * @param IContentHandlerFactory $contentHandlerFactory
98     * @param LoggerInterface $logger
99     * @param Telemetry $telemetry
100     */
101    public function __construct(
102        ServiceOptions $serviceOptions,
103        string $dbDomain,
104        Language $contentLanguage,
105        RevisionStore $revisionStore,
106        TitleFormatter $titleFormatter,
107        UserGroupManager $userGroupManager,
108        UserEditTracker $userEditTracker,
109        WikiPageFactory $wikiPageFactory,
110        UserFactory $userFactory,
111        IContentHandlerFactory $contentHandlerFactory,
112        LoggerInterface $logger,
113        Telemetry $telemetry
114    ) {
115        $serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
116        $this->options = $serviceOptions;
117        $this->dbDomain = $dbDomain;
118        $this->contentLanguage = $contentLanguage;
119        $this->titleFormatter = $titleFormatter;
120        $this->revisionStore = $revisionStore;
121        $this->userGroupManager = $userGroupManager;
122        $this->userEditTracker = $userEditTracker;
123        $this->wikiPageFactory = $wikiPageFactory;
124        $this->userFactory = $userFactory;
125        $this->contentHandlerFactory = $contentHandlerFactory;
126        $this->logger = $logger;
127        $this->telemetry = $telemetry;
128    }
129
130    /**
131     * Inject a CommentFormatter for EventFactory's use. Only needed if you need comment_html populated (T327065).
132     * @param CommentFormatter $commentFormatter
133     * @return void
134     */
135    public function setCommentFormatter( CommentFormatter $commentFormatter ): void {
136        $this->commentFormatter = $commentFormatter;
137    }
138
139    /**
140     * Creates a full user page path
141     *
142     * @param string $userName userName
143     * @return string
144     */
145    private function getUserPageURL( $userName ) {
146        $prefixedUserURL = $this->contentLanguage->getNsText( NS_USER ) . ':' . $userName;
147        $encodedUserURL = wfUrlencode( strtr( $prefixedUserURL, ' ', '_' ) );
148        // The ArticlePath contains '$1' string where the article title should appear.
149        return $this->options->get( 'CanonicalServer' ) .
150            str_replace( '$1', $encodedUserURL, $this->options->get( 'ArticlePath' ) );
151    }
152
153    /**
154     * Converts a revision visibility hidden bitfield to an array with keys
155     * of each of the possible visibility settings name mapped to a boolean.
156     *
157     * @param int $bits revision visibility bitfield
158     * @return array
159     */
160    private static function bitsToVisibilityObject( $bits ) {
161        return [
162            'text'    => !self::isHidden( $bits, RevisionRecord::DELETED_TEXT ),
163            'user'    => !self::isHidden( $bits, RevisionRecord::DELETED_USER ),
164            'comment' => !self::isHidden( $bits, RevisionRecord::DELETED_COMMENT ),
165        ];
166    }
167
168    /**
169     * Checks if RevisionRecord::DELETED_* field is set in the $hiddenBits
170     *
171     * @param int $hiddenBits revision visibility bitfield
172     * @param int $field RevisionRecord::DELETED_* field to check
173     * @return bool
174     */
175    private static function isHidden( $hiddenBits, $field ) {
176        return ( $hiddenBits & $field ) == $field;
177    }
178
179    /**
180     * Creates a full article path
181     *
182     * @param LinkTarget $target article title object
183     * @return string
184     */
185    private function getArticleURL( $target ) {
186        $titleURL = wfUrlencode( $this->titleFormatter->getPrefixedDBkey( $target ) );
187        // The ArticlePath contains '$1' string where the article title should appear.
188        return $this->options->get( 'CanonicalServer' ) .
189            str_replace( '$1', $titleURL, $this->options->get( 'ArticlePath' ) );
190    }
191
192    /**
193     * Given a RevisionRecord $revision, returns an array suitable for
194     * use in mediawiki/revision entity schemas.
195     *
196     * @param RevisionRecord $revision
197     * @param UserIdentity|null $performer
198     * @return array
199     */
200    private function createRevisionRecordAttrs(
201        RevisionRecord $revision,
202        UserIdentity $performer = null
203    ) {
204        $linkTarget = $revision->getPageAsLinkTarget();
205        $attrs = [
206            // Common MediaWiki entity fields
207            'database'           => $this->dbDomain,
208
209            // revision entity fields
210            'page_id'            => $revision->getPageId(),
211            'page_title'         => $this->titleFormatter->getPrefixedDBkey( $linkTarget ),
212            'page_namespace'     => $linkTarget->getNamespace(),
213            'rev_id'             => $revision->getId(),
214            'rev_timestamp'      => self::createDTAttr( $revision->getTimestamp() ),
215            'rev_sha1'           => $revision->getSha1(),
216            'rev_minor_edit'     => $revision->isMinor(),
217            'rev_len'            => $revision->getSize(),
218        ];
219
220        $attrs['rev_content_model'] = $contentModel = $revision->getSlot( SlotRecord::MAIN )->getModel();
221
222        $contentFormat = $revision->getSlot( SlotRecord::MAIN )->getFormat();
223        if ( $contentFormat === null ) {
224            try {
225                $contentFormat = $this->contentHandlerFactory->getContentHandler( $contentModel )->getDefaultFormat();
226            } catch ( MWUnknownContentModelException $e ) {
227                // Ignore, the `rev_content_format` is not required.
228            }
229        }
230        if ( $contentFormat !== null ) {
231            $attrs['rev_content_format'] = $contentFormat;
232        }
233
234        if ( isset( $performer ) ) {
235            $attrs['performer'] = $this->createPerformerAttrs( $performer );
236        }
237
238        // It is possible that the $revision object does not have any content
239        // at the time of RevisionRecordInserted.  This might happen during
240        // a page restore, if the revision 'created' during the restore
241        // has its content hidden.
242        // TODO: In MCR Content::isRedirect should not be used to derive a redirect directly.
243        try {
244            $content = $revision->getContent( SlotRecord::MAIN );
245            if ( $content !== null ) {
246                $attrs['page_is_redirect'] = $content->isRedirect();
247            } else {
248                $attrs['page_is_redirect'] = false;
249            }
250        } catch ( SuppressedDataException $e ) {
251            $attrs['page_is_redirect'] = false;
252        }
253
254        if ( $revision->getComment() !== null && strlen( $revision->getComment()->text ) ) {
255            $attrs['comment'] = $revision->getComment()->text;
256            if ( $this->commentFormatter ) {
257                $attrs['parsedcomment'] = $this->commentFormatter->format( $revision->getComment()->text );
258            }
259        }
260
261        // The rev_parent_id attribute is not required, but when supplied
262        // must have a minimum value of 1, so omit it entirely when there is no
263        // parent revision (i.e. page creation).
264        if ( $revision->getParentId() !== null && $revision->getParentId() > 0 ) {
265            $attrs['rev_parent_id'] = $revision->getParentId();
266        }
267
268        return $attrs;
269    }
270
271    /**
272     * @param RevisionSlots $slots
273     * @return array
274     */
275    private function createSlotRecordsAttrs( RevisionSlots $slots ): array {
276        $attrs = [];
277        foreach ( $slots->getSlots() as $slotRecord ) {
278            $slotAttr = [
279                'rev_slot_content_model' => $slotRecord->getModel(),
280                'rev_slot_sha1' => $slotRecord->getSha1(),
281                'rev_slot_size' => $slotRecord->getSize()
282            ];
283            if ( $slotRecord->hasOrigin() ) {
284                // unclear if necessary to guard against missing origin in this context but since it
285                // might fail on unsaved content we are better safe than sorry
286                $slotAttr['rev_slot_origin_rev_id'] = $slotRecord->getOrigin();
287            }
288            $attrs[$slotRecord->getRole()] = $slotAttr;
289        }
290        return $attrs;
291    }
292
293    /**
294     * Given a UserIdentity $user, returns an array suitable for
295     * use as the performer JSON object in various MediaWiki
296     * entity schemas.
297     * @param UserIdentity $user
298     * @return array
299     */
300    private function createPerformerAttrs( UserIdentity $user ) {
301        $legacyUser = $this->userFactory->newFromUserIdentity( $user );
302        $performerAttrs = [
303            'user_text'   => $user->getName(),
304            'user_groups' => $this->userGroupManager->getUserEffectiveGroups( $user ),
305            'user_is_bot' => $user->isRegistered() && $legacyUser->isBot(),
306        ];
307        if ( $user->getId() ) {
308            $performerAttrs['user_id'] = $user->getId();
309        }
310        if ( $legacyUser->getRegistration() ) {
311            $performerAttrs['user_registration_dt'] =
312                self::createDTAttr( $legacyUser->getRegistration() );
313        }
314        if ( $user->isRegistered() ) {
315            $performerAttrs['user_edit_count'] = $this->userEditTracker->getUserEditCount( $user );
316        }
317
318        return $performerAttrs;
319    }
320
321    /**
322     * Adds a meta subobject to $attrs based on uri and topic and returns it.
323     *
324     * @param string $uri
325     * @param string $schema
326     * @param string $stream
327     * @param array $attrs
328     * @param string|null $wiki wikiId if provided
329     * @param string|null $dt
330     * @return array $attrs + meta sub object
331     */
332    public function createEvent(
333        $uri,
334        $schema,
335        $stream,
336        array $attrs,
337        string $wiki = null,
338        string $dt = null
339    ) {
340        if ( $wiki !== null ) {
341            $wikiRef = WikiMap::getWiki( $wiki );
342            if ( $wikiRef === null ) {
343                $domain = $this->options->get( 'ServerName' );
344            } else {
345                $domain = $wikiRef->getDisplayName();
346            }
347        } else {
348            $domain = $this->options->get( 'ServerName' );
349        }
350
351        $event = [
352            '$schema' => $schema,
353            'meta' => [
354                'uri'        => $uri,
355                'request_id' => $this->telemetry->getRequestId(),
356                'id'         => UIDGenerator::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    public function createCentralNoticeCampaignCreateEvent(
1158        $stream,
1159        $campaignName,
1160        UserIdentity $user,
1161        array $settings,
1162        $summary,
1163        $campaignUrl
1164    ) {
1165        $attrs = $this->createCommonCentralNoticeAttrs( $campaignName, $user, $summary );
1166        $attrs += self::createCentralNoticeCampignSettingsAttrs( $settings );
1167
1168        return $this->createEvent(
1169            $campaignUrl,
1170            '/mediawiki/centralnotice/campaign/create/1.0.0',
1171            $stream,
1172            $attrs
1173        );
1174    }
1175
1176    public function createCentralNoticeCampaignChangeEvent(
1177        $stream,
1178        $campaignName,
1179        UserIdentity $user,
1180        array $settings,
1181        array $priorState,
1182        $summary,
1183        $campaignUrl
1184    ) {
1185        $attrs = $this->createCommonCentralNoticeAttrs( $campaignName, $user, $summary );
1186
1187        $attrs += self::createCentralNoticeCampignSettingsAttrs( $settings );
1188        $attrs[ 'prior_state' ] =
1189            $priorState ? self::createCentralNoticeCampignSettingsAttrs( $priorState ) : [];
1190
1191        return $this->createEvent(
1192            $campaignUrl,
1193            '/mediawiki/centralnotice/campaign/change/1.0.0',
1194            $stream,
1195            $attrs
1196        );
1197    }
1198
1199    public function createCentralNoticeCampaignDeleteEvent(
1200        $stream,
1201        $campaignName,
1202        UserIdentity $user,
1203        array $priorState,
1204        $summary,
1205        $campaignUrl
1206    ) {
1207        $attrs = $this->createCommonCentralNoticeAttrs( $campaignName, $user, $summary );
1208        // As of 2019-06-07 the $beginSettings are *never* set in \Campaign::removeCampaignByName()
1209        // in the CentralNotice extension where the CentralNoticeCampaignChange hook is fired!
1210        $attrs[ 'prior_state' ] =
1211            $priorState ? self::createCentralNoticeCampignSettingsAttrs( $priorState ) : [];
1212
1213        return $this->createEvent(
1214            $campaignUrl,
1215            '/mediawiki/centralnotice/campaign/delete/1.0.0',
1216            $stream,
1217            $attrs
1218        );
1219    }
1220
1221    /**
1222     * Creates a mediawiki/revision/recommendation-create event. Called by other extensions (for
1223     * now, just GrowthExperiments) whenever they generate recommendations; the event will be used
1224     * to keep the search infrastructure informed about available recommendations.
1225     * @param string $stream
1226     * @param string $recommendationType A type, such as 'link' or 'image'.
1227     * @param RevisionRecord $revisionRecord The revision which the recommendation is based on.
1228     * @return array
1229     */
1230    public function createRecommendationCreateEvent(
1231        $stream,
1232        $recommendationType,
1233        RevisionRecord $revisionRecord
1234    ) {
1235        $attrs = $this->createRevisionRecordAttrs( $revisionRecord, $revisionRecord->getUser() );
1236        $attrs['recommendation_type'] = $recommendationType;
1237
1238        return $this->createEvent(
1239            $this->getArticleURL( $revisionRecord->getPageAsLinkTarget() ),
1240            '/mediawiki/revision/recommendation-create/1.0.0',
1241            $stream,
1242            $attrs
1243        );
1244    }
1245}