Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.86% covered (warning)
77.86%
102 / 131
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageChangeEventSerializer
77.86% covered (warning)
77.86%
102 / 131
63.64% covered (warning)
63.64%
7 / 11
28.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 toEvent
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getChangelogKind
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getWikiId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 toCommonAttrs
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 toCreateEvent
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 toEditEvent
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 toMoveEvent
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 toDeleteEvent
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 toUndeleteEvent
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 toVisibilityChangeEvent
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 * @author Andrew Otto <otto@wikimedia.org>
20 */
21
22namespace MediaWiki\Extension\EventBus\Serializers\MediaWiki;
23
24use MediaWiki\Extension\EventBus\Redirects\RedirectTarget;
25use MediaWiki\Extension\EventBus\Serializers\EventSerializer;
26use MediaWiki\Linker\LinkTarget;
27use MediaWiki\Page\ProperPageIdentity;
28use MediaWiki\Revision\RevisionRecord;
29use MediaWiki\User\User;
30use MediaWiki\WikiMap\WikiMap;
31use Wikimedia\Assert\Assert;
32
33/**
34 * Methods to convert from incoming page state changes (via Hooks)
35 * to a mediawiki/page/change event.
36 */
37class PageChangeEventSerializer {
38
39    /**
40     * All page change events will have their $schema URI set to this.
41     * https://phabricator.wikimedia.org/T308017
42     */
43    public const PAGE_CHANGE_SCHEMA_URI = '/mediawiki/page/change/1.3.0';
44
45    /**
46     * There are many kinds of changes that can happen to a MediaWiki pages,
47     * but only a few kinds of changes in a 'changelog' stream.
48     * This maps from a MediaWiki page change kind to a changelog kind.
49     */
50    private const PAGE_CHANGE_KIND_TO_CHANGELOG_KIND_MAP = [
51        'create' => 'insert',
52        'edit' => 'update',
53        'move' => 'update',
54        'visibility_change' => 'update',
55        'delete' => 'delete',
56        'undelete' => 'insert',
57    ];
58
59    /**
60     * @var EventSerializer
61     */
62    private EventSerializer $eventSerializer;
63
64    /**
65     * @var PageEntitySerializer
66     */
67    private PageEntitySerializer $pageEntitySerializer;
68
69    /**
70     * @var UserEntitySerializer
71     */
72    private UserEntitySerializer $userEntitySerializer;
73
74    /**
75     * @var RevisionEntitySerializer
76     */
77    private RevisionEntitySerializer $revisionEntitySerializer;
78
79    /**
80     * @param EventSerializer $eventSerializer
81     * @param PageEntitySerializer $pageEntitySerializer
82     * @param UserEntitySerializer $userEntitySerializer
83     * @param RevisionEntitySerializer $revisionEntitySerializer
84     */
85    public function __construct(
86        EventSerializer $eventSerializer,
87        PageEntitySerializer $pageEntitySerializer,
88        UserEntitySerializer $userEntitySerializer,
89        RevisionEntitySerializer $revisionEntitySerializer
90    ) {
91        $this->eventSerializer = $eventSerializer;
92        $this->pageEntitySerializer = $pageEntitySerializer;
93        $this->userEntitySerializer = $userEntitySerializer;
94        $this->revisionEntitySerializer = $revisionEntitySerializer;
95    }
96
97    /**
98     * Uses EventSerializer to create the mediawiki/page/change event for the given $eventAttrs
99     * @param string $stream
100     * @param ProperPageIdentity $page
101     * @param array $eventAttrs
102     * @return array
103     */
104    private function toEvent(
105        string $stream,
106        ProperPageIdentity $page,
107        array $eventAttrs
108    ): array {
109        // NOTE: It would be better if wiki domain name was fetched and passed into createEvent,
110        // rather than forcing EventSerializer->createEvent to look up the domain itself.
111        // However, this would require changing the createEvent method signature, which is used
112        // by CirrusSearch extension.
113        // See: https://phabricator.wikimedia.org/T392516
114        return $this->eventSerializer->createEvent(
115            self::PAGE_CHANGE_SCHEMA_URI,
116            $stream,
117            $this->pageEntitySerializer->canonicalPageURL( $page ),
118            $eventAttrs,
119            self::getWikiId( $page )
120        );
121    }
122
123    /**
124     * Returns the appropriate changelog kind given a pageChangeKind.
125     * @param string $pageChangeKind
126     * @return string
127     */
128    private static function getChangelogKind( string $pageChangeKind ): string {
129        Assert::parameter(
130            array_key_exists( $pageChangeKind, self::PAGE_CHANGE_KIND_TO_CHANGELOG_KIND_MAP ),
131            '$pageChangeKind',
132            "Unsupported pageChangeKind '$pageChangeKind'.  Must be one of " .
133            implode( ',', array_keys( self::PAGE_CHANGE_KIND_TO_CHANGELOG_KIND_MAP ) )
134        );
135
136        return self::PAGE_CHANGE_KIND_TO_CHANGELOG_KIND_MAP[$pageChangeKind];
137    }
138
139    /**
140     * Return the page's wikiId, or if that returns false,
141     * return WikiMap::getCurrentWikiId.
142     *
143     * @param ProperPageIdentity $page
144     * @return string
145     */
146    private static function getWikiId( ProperPageIdentity $page ): string {
147        // Get the wikiId.  page's getWikiId can return false.
148        // Fallback to global WikiMap.
149        return $page->getWikiId() ?: WikiMap::getCurrentWikiId();
150    }
151
152    /**
153     * DRY helper to set event fields common to all page change events.
154     * @param string $page_change_kind
155     * @param string $dt
156     * @param ProperPageIdentity $page
157     * @param User|null $performer
158     * @param RevisionRecord|null $currentRevision
159     * @param RedirectTarget|null $redirectTarget
160     * @param string|null $comment
161     * @return array
162     */
163    private function toCommonAttrs(
164        string $page_change_kind,
165        string $dt,
166        ProperPageIdentity $page,
167        ?User $performer,
168        ?RevisionRecord $currentRevision = null,
169        ?RedirectTarget $redirectTarget = null,
170        ?string $comment = null
171    ): array {
172        $eventAttrs = [
173            'changelog_kind' => self::getChangelogKind( $page_change_kind ),
174            'page_change_kind' => $page_change_kind,
175            'dt' => $dt,
176            'wiki_id' => self::getWikiId( $page ),
177            'page' => $this->pageEntitySerializer->toArray( $page, $redirectTarget ),
178        ];
179
180        if ( $performer !== null ) {
181            $eventAttrs['performer'] = $this->userEntitySerializer->toArray( $performer );
182        }
183
184        if ( $comment !== null ) {
185            $eventAttrs['comment'] = $comment;
186        }
187
188        if ( $currentRevision !== null ) {
189            $eventAttrs['revision'] = $this->revisionEntitySerializer->toArray( $currentRevision );
190        }
191
192        return $eventAttrs;
193    }
194
195    /**
196     * Converts from the given page and RevisionRecord to a page_change_kind: create event.
197     *
198     * @param string $stream
199     * @param ProperPageIdentity $page
200     * @param User $performer
201     * @param RevisionRecord $currentRevision
202     * @param RedirectTarget|null $redirectTarget
203     * @return array
204     */
205    public function toCreateEvent(
206        string $stream,
207        ProperPageIdentity $page,
208        User $performer,
209        RevisionRecord $currentRevision,
210        ?RedirectTarget $redirectTarget = null
211    ): array {
212        $eventAttrs = $this->toCommonAttrs(
213            'create',
214            $this->eventSerializer->timestampToDt( $currentRevision->getTimestamp() ),
215            $page,
216            $performer,
217            $currentRevision,
218            $redirectTarget,
219            null
220        );
221
222        return $this->toEvent( $stream, $page, $eventAttrs );
223    }
224
225    /**
226     * Converts from the given page and RevisionRecord to a page_change_kind: edit event.
227     *
228     * @param string $stream
229     * @param ProperPageIdentity $page
230     * @param User $performer
231     * @param RevisionRecord $currentRevision
232     * @param RedirectTarget|null $redirectTarget
233     * @param RevisionRecord|null $parentRevision
234     * @return array
235     */
236    public function toEditEvent(
237        string $stream,
238        ProperPageIdentity $page,
239        User $performer,
240        RevisionRecord $currentRevision,
241        ?RedirectTarget $redirectTarget = null,
242        ?RevisionRecord $parentRevision = null
243    ): array {
244        $eventAttrs = $this->toCommonAttrs(
245            'edit',
246            $this->eventSerializer->timestampToDt( $currentRevision->getTimestamp() ),
247            $page,
248            $performer,
249            $currentRevision,
250            $redirectTarget,
251            null
252        );
253
254        // On edit, the prior state is all about the previous revision.
255        if ( $parentRevision !== null ) {
256            $priorStateAttrs = [];
257            $priorStateAttrs['revision'] = $this->revisionEntitySerializer->toArray( $parentRevision );
258            $eventAttrs['prior_state'] = $priorStateAttrs;
259        }
260
261        return $this->toEvent( $stream, $page, $eventAttrs );
262    }
263
264    /**
265     * Converts from the given page, RevisionRecord
266     * and old title LinkTarget to a page_change_kind: move event.
267     *
268     * @param string $stream
269     * @param ProperPageIdentity $page
270     * @param User $performer
271     * @param RevisionRecord $currentRevision
272     * @param RevisionRecord $parentRevision
273     * @param LinkTarget|ProperPageIdentity $oldTitle
274     * @param string $reason
275     * @param ProperPageIdentity|null $createdRedirectWikiPage
276     * @param RedirectTarget|null $redirectTarget
277     * @return array
278     */
279    public function toMoveEvent(
280        string $stream,
281        ProperPageIdentity $page,
282        User $performer,
283        RevisionRecord $currentRevision,
284        RevisionRecord $parentRevision,
285        /* LinkTarget|ProperPageIdentity */ $oldTitle,
286        string $reason,
287        ?ProperPageIdentity $createdRedirectWikiPage = null,
288        ?RedirectTarget $redirectTarget = null
289    ): array {
290        $eventAttrs = $this->toCommonAttrs(
291            'move',
292            // NOTE: This uses the newly created revision's timestamp as the page move event time,
293            // for lack of a better 'move time'.
294            $this->eventSerializer->timestampToDt( $currentRevision->getTimestamp() ),
295            $page,
296            $performer,
297            $currentRevision,
298            $redirectTarget,
299            // NOTE: the reason for the page move is used to generate the comment
300            // on the revision created by the page move, but it is not the same!
301            $reason
302        );
303
304        // If a new redirect page was created during this move, then include
305        // some information about it.
306        if ( $createdRedirectWikiPage ) {
307            $eventAttrs['created_redirect_page'] = $this->pageEntitySerializer->toArray( $createdRedirectWikiPage );
308        }
309
310        // On move, the prior state is about page title, namespace, and also the previous revision.
311        // (Page moves create a new revision of a page).
312        $priorStateAttrs = [];
313
314        // Include old page_title and namespace to prior_state, these are primary
315        // arguments to the move. Skip page_id as it could not have changed.
316        $priorStateAttrs['page'] = [
317            'page_title' => $this->pageEntitySerializer->formatLinkTarget( $oldTitle ),
318            'namespace_id' => $oldTitle->getNamespace(),
319        ];
320
321        // add parent revision info in prior_state, since a page move creates a new revision.
322        $priorStateAttrs['revision'] = $this->revisionEntitySerializer->toArray( $parentRevision );
323
324        $eventAttrs['prior_state'] = $priorStateAttrs;
325
326        return $this->toEvent( $stream, $page, $eventAttrs );
327    }
328
329    /**
330     * Converts from the given page, RevisionRecord to a page_change_kind: delete event.
331     *
332     * NOTE: If $isSuppression is true, the current revision info emitted by this even will have
333     * all of its visibility settings set to false.
334     * A consumer of this event probably doesn't care, because they should delete the page
335     * and revision in response to this event anyway.
336     *
337     * @param string $stream
338     * @param ProperPageIdentity $page
339     * @param User|null $performer
340     * @param RevisionRecord $currentRevision
341     * @param string $reason
342     * @param string|null $eventTimestamp
343     * @param int|null $archivedRevisionCount
344     * @param RedirectTarget|null $redirectTarget
345     * @param bool $isSuppression
346     *  If true, the current revision info emitted by this even will have
347     *  all of its visibility settings set to false.
348     *  A consumer of this event probably doesn't care, because they should delete the page
349     *  and revision in response to this event anyway.
350     * @return array
351     */
352    public function toDeleteEvent(
353        string $stream,
354        ProperPageIdentity $page,
355        ?User $performer,
356        RevisionRecord $currentRevision,
357        string $reason,
358        ?string $eventTimestamp = null,
359        ?int $archivedRevisionCount = null,
360        ?RedirectTarget $redirectTarget = null,
361        bool $isSuppression = false
362    ): array {
363        $eventAttrs = $this->toCommonAttrs(
364            'delete',
365            $this->eventSerializer->timestampToDt( $eventTimestamp ),
366            $page,
367            $performer,
368            $currentRevision,
369            $redirectTarget,
370            $reason
371        );
372
373        // page delete specific fields:
374        if ( $archivedRevisionCount !== null ) {
375            $eventAttrs['page']['revision_count'] = $archivedRevisionCount;
376        }
377
378        // If this is a full page suppression, then we need to represent that fact that
379        // the current revision (and also all revisions of this page) is having its visibility changed
380        // to fully hidden, AKA SUPPRESSED_ALL, and delete any fields that might contain information
381        // that has been suppressed.
382        // NOTE: It would be better if $currentRevision itself had its visibility settings
383        // set to the same as the 'deleted/archived' revision, but it is not because
384        // MediaWiki is pretty weird with archived revisions.
385        // See: https://phabricator.wikimedia.org/T308017#8339347
386        if ( $isSuppression ) {
387            $eventAttrs['revision'] = array_merge(
388                $eventAttrs['revision'],
389                $this->revisionEntitySerializer->bitsToVisibilityAttrs( RevisionRecord::SUPPRESSED_ALL )
390            );
391
392            unset( $eventAttrs['revision']['rev_size'] );
393            unset( $eventAttrs['revision']['rev_sha1'] );
394            unset( $eventAttrs['revision']['comment'] );
395            unset( $eventAttrs['revision']['editor'] );
396            unset( $eventAttrs['revision']['content_slots'] );
397
398            // $currentRevision actually has the prior revision visibility info in the case of page suppression.
399            $eventAttrs['prior_state']['revision'] = $this->revisionEntitySerializer->bitsToVisibilityAttrs(
400                $currentRevision->getVisibility()
401            );
402        }
403
404        return $this->toEvent( $stream, $page, $eventAttrs );
405    }
406
407    /**
408     * Converts from the given page, RevisionRecord to a page_change_kind: undelete event.
409     *
410     * @param string $stream
411     * @param ProperPageIdentity $page
412     * @param User $performer
413     * @param RevisionRecord $currentRevision
414     * @param string $reason
415     * @param RedirectTarget|null $redirectTarget
416     * @param string|null $eventTimestamp
417     * @param int|null $oldPageID
418     * @return array
419     */
420    public function toUndeleteEvent(
421        string $stream,
422        ProperPageIdentity $page,
423        User $performer,
424        RevisionRecord $currentRevision,
425        string $reason,
426        ?RedirectTarget $redirectTarget = null,
427        ?string $eventTimestamp = null,
428        ?int $oldPageID = null
429    ): array {
430        $eventAttrs = $this->toCommonAttrs(
431            'undelete',
432            $this->eventSerializer->timestampToDt( $eventTimestamp ),
433            $page,
434            $performer,
435            $currentRevision,
436            $redirectTarget,
437            $reason
438        );
439
440        // If this page had a different id in the archive table,
441        // then save it as the prior_state page_id.  This will
442        // be the page_id that the page had before it was deleted,
443        // which is the same as the page_id that it had while it was
444        // in the archive table.
445        // Usually page_id will be the same, but there are some historical
446        // edge cases where a new page_id is created as part of an undelete.
447        if ( $oldPageID && $oldPageID != $page->getId() ) {
448            $eventAttrs['prior_state'] = [
449                'page' => [
450                    'page_id' => $oldPageID
451                ]
452            ];
453        }
454
455        return $this->toEvent( $stream, $page, $eventAttrs );
456    }
457
458    /**
459     * Converts from the given page, RevisionRecord and previous RevisionRecord's visibility (deleted)
460     * bitfield to a page_change_kind: visibility_change event.
461     *
462     * @param string $stream
463     * @param ProperPageIdentity $page
464     * @param User|null $performer
465     * @param RevisionRecord $currentRevision
466     * @param int $priorVisibilityBitfield
467     * @param string|null $eventTimestamp
468     * @return array
469     */
470    public function toVisibilityChangeEvent(
471        string $stream,
472        ProperPageIdentity $page,
473        ?User $performer,
474        RevisionRecord $currentRevision,
475        int $priorVisibilityBitfield,
476        ?string $eventTimestamp = null
477    ) {
478        $eventAttrs = $this->toCommonAttrs(
479            'visibility_change',
480            $this->eventSerializer->timestampToDt( $eventTimestamp ),
481            $page,
482            $performer,
483            $currentRevision,
484            # NOTE: ArticleRevisionVisibilitySet does not give us the 'reason' (comment)
485            # the visibility has been changed.  This info is provided in the UI by the user,
486            # where does it go?
487            # https://phabricator.wikimedia.org/T321411
488            null
489        );
490
491        // During a visibility change, we are only representing the change to the revision's
492        // visibility. The rev_id that is being modified is at revision.rev_id.
493        // The rev_id has not changed. The prior_state.revision object will not contain
494        // any duplicate information about this revision. It will only contain the
495        // prior visibility fields for this revision that have been changed
496        $priorVisibilityFields = $this->revisionEntitySerializer->bitsToVisibilityAttrs( $priorVisibilityBitfield );
497        $eventAttrs['prior_state']['revision'] = [];
498        foreach ( $priorVisibilityFields as $key => $value ) {
499            // Only set the old visibility field in prior state if it has changed.
500            if ( $eventAttrs['revision'][$key] !== $value ) {
501                $eventAttrs['prior_state']['revision'][$key] = $value;
502            }
503        }
504
505        return $this->toEvent( $stream, $page, $eventAttrs );
506    }
507}