Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 90
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
BannerMessage
0.00% covered (danger)
0.00%
0 / 90
0.00% covered (danger)
0.00%
0 / 8
506
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDbKey
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 existsInLang
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContents
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 toHtml
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 update
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 sanitize
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3use MediaWiki\Content\ContentHandler;
4use MediaWiki\Content\TextContent;
5use MediaWiki\Context\IContextSource;
6use MediaWiki\MediaWikiServices;
7use MediaWiki\Parser\Sanitizer;
8use MediaWiki\Revision\SlotRecord;
9use MediaWiki\Title\Title;
10use MediaWiki\User\User;
11use Wikimedia\Rdbms\IDBAccessObject;
12
13class BannerMessage {
14
15    private const SPAN_TAG_PLACEHOLDER_START = '%%%spantagplaceholderstart%%%';
16    private const SPAN_TAG_PLACEHOLDER_END = '%%%spantagplaceholderend%%%';
17
18    public function __construct(
19        private readonly string $banner_name,
20        private readonly string $name,
21    ) {
22    }
23
24    /**
25     * @param string $lang
26     * @param int $namespace
27     * @return Title|null
28     */
29    public function getTitle( $lang, $namespace = NS_MEDIAWIKI ) {
30        return Title::newFromText( $this->getDbKey( $lang, $namespace ), $namespace );
31    }
32
33    /**
34     * Obtains the key of the message as stored in the database. This varies depending on namespace
35     *  - in the MediaWiki namespace messages are Centralnotice-{banner name}-{message name}/{lang}
36     *  -- except for the content language which is stored without the /{lang} extension
37     *  - in the CN Banner namespace messages are {banner name}-{message name}/{lang}
38     *
39     * @param string|null $lang Language code
40     * @param int $namespace Namespace to get key for
41     *
42     * @return string Message database key
43     * @throws RangeException
44     */
45    public function getDbKey( $lang = null, $namespace = NS_MEDIAWIKI ) {
46        global $wgLanguageCode;
47
48        if ( $namespace === NS_MEDIAWIKI ) {
49            return ( $lang === null || $lang === $wgLanguageCode ) ?
50                "Centralnotice-{$this->banner_name}-{$this->name}" :
51                "Centralnotice-{$this->banner_name}-{$this->name}/{$lang}";
52        } elseif ( $namespace === NS_CN_BANNER ) {
53            return "{$this->banner_name}-{$this->name}/{$lang}";
54        } else {
55            throw new RangeException(
56                "Namespace '$namespace' not known for having CentralNotice messages."
57            );
58        }
59    }
60
61    /**
62     * Return the whether the message exists, without language fallback.
63     * @param string|null $lang
64     * @return bool
65     */
66    public function existsInLang( $lang ) {
67        return $this->getTitle( $lang )->exists();
68    }
69
70    /**
71     * Obtain the raw contents of the message; stripping out the stupid <message-name> if it's blank
72     *
73     * @param null|string $lang
74     * @return null|string Will be null if the message does not exist, otherwise will be
75     * the contents of the message.
76     */
77    public function getContents( $lang ) {
78        if ( $this->existsInLang( $lang ) ) {
79            $dbKey = $this->getDbKey();
80            $revisionLookup = MediaWikiServices::getInstance()->getRevisionLookup();
81            $rev = $revisionLookup->getRevisionByTitle( $this->getTitle( $lang ) );
82
83            if ( !$rev ) {
84                // Try harder, might have just been created, otherwise the title wouldn't exist
85                $rev = $revisionLookup->getRevisionByTitle(
86                    $this->getTitle( $lang ),
87                    IDBAccessObject::READ_LATEST
88                );
89            }
90
91            if ( !$rev ) {
92                return null;
93            }
94
95            $content = $rev->getContent( SlotRecord::MAIN );
96            /** @var TextContent $content */
97            '@phan-var TextContent $content';
98            $msg = $content->getText();
99            if ( $msg === "&lt;{$dbKey}&gt;" ) {
100                $msg = '';
101            }
102            return $msg;
103        } else {
104            return null;
105        }
106    }
107
108    public function toHtml( IContextSource $context ): string {
109        global $wgNoticeUseLanguageConversion;
110        $lang = $context->getLanguage();
111        if ( $wgNoticeUseLanguageConversion ) {
112            $lang = MediaWikiServices::getInstance()->getLanguageFactory()
113                ->getParentLanguage( $lang->getCode() ) ?? $lang;
114        }
115
116        $text = self::sanitize(
117            $context->msg( $this->getDbKey() )->inLanguage( $lang )->text() );
118
119        return $text;
120    }
121
122    /**
123     * Add or update message contents
124     * @param string $translation
125     * @param string|null $lang
126     * @param User $user
127     * @param string|null $summary
128     */
129    public function update( $translation, $lang, $user, $summary = null ) {
130        global $wgNoticeUseTranslateExtension, $wgLanguageCode;
131
132        if ( $summary === null ) {
133            // default edit summary
134            // TODO make this consistent throughout CN
135            $summary = '/* CN admin */';
136        }
137
138        $savePage = static function ( $title, $text ) use ( $summary, $user ) {
139            $wikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
140
141            $content = ContentHandler::makeContent( $text, $title );
142            $tags = [ 'centralnotice' ];
143            $wikiPage->doUserEditContent(
144                $content,
145                $user,
146                $summary,
147                EDIT_FORCE_BOT,
148                false,
149                $tags
150            );
151
152            return $wikiPage;
153        };
154
155        $page = $savePage( $this->getTitle( $lang ), $translation );
156        Banner::protectBannerContent( $page, $user );
157
158        // If we're using translate : group review; create and protect the english page
159        if ( $wgNoticeUseTranslateExtension
160            && ( $lang === $wgLanguageCode )
161            && BannerMessageGroup::isUsingGroupReview()
162        ) {
163            $page = $savePage( $this->getTitle( $lang, NS_CN_BANNER ), $translation );
164            Banner::protectBannerContent( $page, $user );
165        }
166    }
167
168    public static function sanitize( string $text ): string {
169        // First, remove any occurrences of the placeholders used to preserve span tags.
170        $text = str_replace( self::SPAN_TAG_PLACEHOLDER_START, '', $text );
171        $text = str_replace( self::SPAN_TAG_PLACEHOLDER_END, '', $text );
172
173        // Remove and save <span> tags so they don't get removed by sanitization; allow
174        // only class attributes.
175        $spanTags = [];
176        $text = preg_replace_callback(
177            '/(<\/?span\s*(?:class\s?=\s?([\'"])[a-zA-Z0-9_ -]+(\2))?\s*>)/',
178            static function ( $matches ) use ( &$spanTags ) {
179                $spanTags[] = $matches[ 1 ];
180                return BannerMessage::SPAN_TAG_PLACEHOLDER_START .
181                    ( count( $spanTags ) - 1 ) .
182                    BannerMessage::SPAN_TAG_PLACEHOLDER_END;
183            },
184            $text
185        );
186
187        $text = Sanitizer::stripAllTags( $text );
188        $text = Sanitizer::escapeHtmlAllowEntities( $text );
189
190        // Restore span tags
191        $text = preg_replace_callback(
192            '/(?:' . self::SPAN_TAG_PLACEHOLDER_START . '(\d+)' .
193                self::SPAN_TAG_PLACEHOLDER_END . ')/',
194
195            static function ( $matches ) use ( $spanTags ) {
196                $index = (int)$matches[ 1 ];
197                // This should never happen, but let's be safe.
198                if ( !isset( $spanTags[ $index ] ) ) {
199                    return '';
200                }
201                return $spanTags[ $index ];
202            },
203
204            $text
205        );
206
207        return $text;
208    }
209}