Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.86% covered (warning)
52.86%
351 / 664
47.06% covered (danger)
47.06%
32 / 68
CRAP
0.00% covered (danger)
0.00%
0 / 1
Banner
52.86% covered (warning)
52.86%
351 / 664
47.06% covered (danger)
47.06%
32 / 68
2841.44
0.00% covered (danger)
0.00%
0 / 1
 fromId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 fromName
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 newFromName
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getName
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 allocateToAnon
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 allocateToLoggedIn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setAllocation
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getCategory
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCategory
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getAllUsedCategories
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 sanitizeRenderedCategory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isArchived
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isTemplate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setIsTemplate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 populateBasicData
74.36% covered (warning)
74.36%
29 / 39
0.00% covered (danger)
0.00%
0 / 1
7.83
 setBasicDataDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initializeDbBasicData
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 saveBasicData
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 getDevices
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setDevices
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
5.06
 populateDeviceTargetData
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 markDeviceTargetDataDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveDeviceTargetData
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 getMixins
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setMixins
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
4.77
 populateMixinData
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
4.34
 markMixinDataDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveMixinData
36.84% covered (danger)
36.84%
7 / 19
0.00% covered (danger)
0.00%
0 / 1
8.03
 getPriorityLanguages
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPriorityLanguages
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 populatePriorityLanguageData
33.33% covered (danger)
33.33%
4 / 12
0.00% covered (danger)
0.00%
0 / 1
8.74
 markPriorityLanguageDataDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 savePriorityLanguageData
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
13.26
 getDbKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCampaignNames
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 getIncludedTemplates
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBodyContent
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setBodyContent
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 populateBodyContent
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 markBodyContentDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveBodyContent
66.67% covered (warning)
66.67%
16 / 24
0.00% covered (danger)
0.00%
0 / 1
5.93
 getMessageField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMessageFieldsFromCache
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 invalidateCache
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getMessageFieldsCacheKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 extractMessageFields
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 getAvailableLanguages
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 save
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
4.25
 initializeDbForNewBanner
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveBannerInternal
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 archive
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 cloneBanner
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 remove
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeBanner
70.37% covered (warning)
70.37%
19 / 27
0.00% covered (danger)
0.00%
0 / 1
4.42
 addTag
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 removeTag
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getCampaignBanners
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
30
 getBannerSettings
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 getHistoricalBanner
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
6
 addFromBannerTemplate
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
2.00
 addBanner
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
6.20
 logBannerChange
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 isValidBannerName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 exists
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
4.16
 getMessageFieldForBanner
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 protectBannerContent
73.91% covered (warning)
73.91%
17 / 23
0.00% covered (danger)
0.00%
0 / 1
5.44
1<?php
2/**
3 * This file is part of the CentralNotice Extension to MediaWiki
4 * https://www.mediawiki.org/wiki/Extension:CentralNotice
5 *
6 * @section LICENSE
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * http://www.gnu.org/copyleft/gpl.html
21 *
22 * @file
23 */
24
25use MediaWiki\Content\ContentHandler;
26use MediaWiki\Content\TextContent;
27use MediaWiki\Context\RequestContext;
28use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
29use MediaWiki\Extension\Translate\MessageLoading\RebuildMessageIndexJob;
30use MediaWiki\Extension\Translate\Services;
31use MediaWiki\Json\FormatJson;
32use MediaWiki\MediaWikiServices;
33use MediaWiki\Page\WikiPage;
34use MediaWiki\Parser\ParserOptions;
35use MediaWiki\Revision\SlotRecord;
36use MediaWiki\Title\Title;
37use MediaWiki\User\User;
38use Wikimedia\ObjectCache\WANObjectCache;
39use Wikimedia\Rdbms\IDatabase;
40use Wikimedia\Rdbms\IExpression;
41use Wikimedia\Rdbms\LikeValue;
42
43/**
44 * CentralNotice banner object. Banners are pieces of rendered wikimarkup
45 * injected as HTML onto MediaWiki pages via the sitenotice hook.
46 *
47 * - They are allowed to be specific to devices and user status.
48 * - They allow 'mixins', pieces of javascript that add additional standard
49 *   functionality to the banner.
50 * - They have a concept of 'messages' which are translatable strings marked
51 *   out by {{{name}}} in the banner body.
52 *
53 * @see BannerMessage
54 * @see BannerRenderer
55 * @see BannerMixin
56 */
57class Banner {
58    /** Indicates a revision of a banner that can be translated. */
59    public const TRANSLATE_BANNER_TAG = 'banner:translate';
60
61    /**
62     * Keys indicate a group of properties (which should be a 1-to-1 match to
63     * a database table.) If the value is null, it means the data is not yet
64     * loaded. True means the data is clean and not modified. False means the
65     * data should be saved on the next call to save().
66     *
67     * Most functions should only ever set the flag to true; flags will be
68     * reset to false in save().
69     *
70     * @var (null|bool)[]
71     */
72    private $dirtyFlags = [
73        'content' => null,
74        'messages' => null,
75        'basic' => null,
76        'devices' => null,
77        'mixins' => null,
78        'prioritylang' => null,
79    ];
80
81    /** @var int Unique database identifier key. */
82    private $id;
83
84    /** Unique human-friendly name of banner. */
85    private ?string $name = null;
86
87    /** True if the banner should be allocated to anonymous users. */
88    private bool $allocateAnon = false;
89
90    /** True if the banner should be allocated to logged-in users. */
91    private bool $allocateLoggedIn = false;
92
93    /** Category that the banner belongs to. Will be special value expanded. */
94    private string $category = '{{{campaign}}}';
95
96    /** True if archived and hidden from the default view. */
97    private bool $archived = false;
98
99    /** @var string[] Devices this banner should be allocated to in the form
100     * {Device ID => Device header name}
101     */
102    private $devices = [];
103
104    /** @var string[] Names of enabled mixins */
105    private $mixins = [];
106
107    /** @var string[] Language codes considered a priority for translation. */
108    private $priorityLanguages = [];
109
110    /** Wikitext content of the banner */
111    private string $bodyContent = '';
112
113    private bool $runTranslateJob = false;
114
115    /** Is banner meant to be used as a template for other banners? */
116    private bool $template = false;
117
118    /**
119     * Create a banner object from a known ID. Must already be
120     * an object in the database. If a fully new banner is to be created
121     * use {@link newFromName}.
122     *
123     * @param int $id Unique database ID of the banner
124     */
125    public static function fromId( int $id ): self {
126        $obj = new self();
127        $obj->id = $id;
128        return $obj;
129    }
130
131    /**
132     * Create a banner object from a known banner name. Must already be
133     * an object in the database. If a fully new banner is to be created
134     * use {@link newFromName}.
135     *
136     * @throws BannerDataException
137     */
138    public static function fromName( string $name ): self {
139        if ( !self::isValidBannerName( $name ) ) {
140            throw new BannerDataException( "Invalid banner name supplied." );
141        }
142
143        $obj = new self();
144        $obj->name = $name;
145        return $obj;
146    }
147
148    /**
149     * Create a brand new banner object.
150     * @throws BannerDataException
151     */
152    public static function newFromName( string $name ): self {
153        if ( !self::isValidBannerName( $name ) ) {
154            throw new BannerDataException( "Invalid banner name supplied." );
155        }
156
157        $obj = new self();
158        $obj->name = $name;
159
160        foreach ( $obj->dirtyFlags as &$value ) {
161            $value = true;
162        }
163        unset( $value );
164
165        return $obj;
166    }
167
168    /**
169     * Get the unique ID for this banner.
170     *
171     * @return int
172     */
173    public function getId() {
174        $this->populateBasicData();
175        return $this->id;
176    }
177
178    /**
179     * Get the unique name for this banner.
180     *
181     * This specifically does not include namespace or other prefixing.
182     *
183     * @return null|string
184     */
185    public function getName() {
186        // Optimization: Speed up BannerMessageGroup's getKeys and getDefinitions.
187        //
188        // BannerMessageGroup calls self::fromName which populates $this->name.
189        // Translate calls BannerMessageGroup::getKeys(), which calls
190        // self::getMessageFieldsFromCache to load the message keys.
191        // self::getMessageFieldsFromCache calls self::getMessageFieldsCacheKey which itself calls
192        // this method. self::populateBasicData does a database query, which is not needed
193        // if we only need to know the banner name, which we already have.
194        if ( $this->name === null ) {
195            $this->populateBasicData();
196        }
197        return $this->name;
198    }
199
200    /**
201     * Should we allocate this banner to anonymous users?
202     */
203    public function allocateToAnon( bool $fromPrimary = false ): bool {
204        $this->populateBasicData( $fromPrimary );
205        return $this->allocateAnon;
206    }
207
208    /**
209     * Should we allocate this banner to logged-in users?
210     */
211    public function allocateToLoggedIn( bool $fromPrimary = false ): bool {
212        $this->populateBasicData( $fromPrimary );
213        return $this->allocateLoggedIn;
214    }
215
216    /**
217     * Set user state allocation properties for this banner
218     *
219     * @param bool $anon Should the banner be allocated to logged-out users?
220     * @param bool $loggedIn Should the banner be allocated to logged-in users?
221     */
222    public function setAllocation( bool $anon, bool $loggedIn ): self {
223        $this->populateBasicData();
224
225        if ( ( $this->allocateAnon !== $anon ) || ( $this->allocateLoggedIn !== $loggedIn ) ) {
226            $this->setBasicDataDirty();
227            $this->allocateAnon = $anon;
228            $this->allocateLoggedIn = $loggedIn;
229        }
230
231        return $this;
232    }
233
234    /**
235     * Get the banner category.
236     *
237     * The category is the name of the cookie stored on the user's computer.
238     * In this way, banners in the same category may share settings.
239     */
240    public function getCategory(): string {
241        $this->populateBasicData();
242        return $this->category;
243    }
244
245    /**
246     * Set the banner category.
247     *
248     * @see Banner->getCategory()
249     */
250    public function setCategory( string $value ): self {
251        $this->populateBasicData();
252
253        if ( $this->category !== $value ) {
254            $this->setBasicDataDirty();
255            $this->category = $value;
256        }
257
258        return $this;
259    }
260
261    /**
262     * Gets an array of all categories currently seen attached to banners
263     * @return string[]
264     */
265    public static function getAllUsedCategories() {
266        $res = CNDatabase::getReplicaDb()->newSelectQueryBuilder()
267            ->select( 'tmp_category' )
268            ->distinct()
269            ->from( 'cn_templates' )
270            ->caller( __METHOD__ )
271            ->orderBy( 'tmp_category' )
272            ->fetchResultSet();
273
274        $categories = [];
275        foreach ( $res as $row ) {
276            $categories[$row->tmp_category] = $row->tmp_category;
277        }
278        return $categories;
279    }
280
281    /**
282     * Remove invalid characters from a category string that has been magic
283     * word expanded.
284     *
285     * @param string $cat Category string to sanitize
286     */
287    public static function sanitizeRenderedCategory( string $cat ): string {
288        return preg_replace( '/[^a-zA-Z0-9_]/', '', $cat );
289    }
290
291    /**
292     * Should the banner be considered archived and hidden from the default view?
293     */
294    public function isArchived(): bool {
295        $this->populateBasicData();
296        return $this->archived;
297    }
298
299    /**
300     * Is this banner meant to be used as a template for other banners?
301     *
302     * @return bool
303     * @throws BannerDataException
304     * @throws BannerExistenceException
305     */
306    public function isTemplate(): bool {
307        $this->populateBasicData();
308        return $this->template;
309    }
310
311    /**
312     * Mark banner as a template
313     *
314     * @param bool $value
315     * @throws BannerDataException
316     * @throws BannerExistenceException
317     */
318    public function setIsTemplate( bool $value ) {
319        $this->populateBasicData();
320        if ( $this->template !== $value ) {
321            $this->setBasicDataDirty();
322            $this->template = $value;
323        }
324    }
325
326    /**
327     * Populates basic banner data by querying the cn_templates table
328     *
329     * @throws BannerDataException If neither a name nor an ID can be used to query for data
330     * @throws BannerExistenceException If no banner data was received
331     */
332    private function populateBasicData( bool $fromPrimary = false ) {
333        if ( $this->dirtyFlags['basic'] !== null ) {
334            return;
335        }
336
337        $db = $fromPrimary ? CNDatabase::getPrimaryDb() : CNDatabase::getReplicaDb();
338
339        // What are we using to select on?
340        if ( $this->name !== null ) {
341            $selector = [ 'tmp_name' => $this->name ];
342        } elseif ( $this->id !== null ) {
343            $selector = [ 'tmp_id' => $this->id ];
344        } else {
345            throw new BannerDataException( 'Cannot retrieve banner data without name or ID.' );
346        }
347
348        // Query!
349        $rowRes = $db->newSelectQueryBuilder()
350            ->select( [
351                'tmp_id',
352                'tmp_name',
353                'tmp_display_anon',
354                'tmp_display_account',
355                'tmp_archived',
356                'tmp_category',
357                'tmp_is_template'
358            ] )
359            ->from( 'cn_templates' )
360            ->where( $selector )
361            ->caller( __METHOD__ )
362            ->fetchResultSet();
363
364        // Extract the dataz!
365        $row = $rowRes->fetchObject();
366        if ( $row ) {
367            $this->id = (int)$row->tmp_id;
368            $this->name = $row->tmp_name;
369            $this->allocateAnon = (bool)$row->tmp_display_anon;
370            $this->allocateLoggedIn = (bool)$row->tmp_display_account;
371            $this->archived = (bool)$row->tmp_archived;
372            $this->category = $row->tmp_category;
373            $this->template = (bool)$row->tmp_is_template;
374        } else {
375            $keyStr = [];
376            foreach ( $selector as $key => $value ) {
377                $keyStr[] = "{$key} = {$value}";
378            }
379            $keyStr = implode( " AND ", $keyStr );
380            throw new BannerExistenceException(
381                "No banner exists where {$keyStr}. Could not load."
382            );
383        }
384
385        // Set the dirty flag to not dirty because we just loaded clean data
386        $this->setBasicDataDirty( false );
387    }
388
389    /**
390     * Sets the flag which will save basic metadata on next save()
391     */
392    private function setBasicDataDirty( bool $dirty = true ) {
393        $this->dirtyFlags['basic'] = $dirty;
394    }
395
396    /**
397     * Helper function to initializeDbForNewBanner()
398     */
399    private function initializeDbBasicData( IDatabase $db ) {
400        $db->newInsertQueryBuilder()
401            ->insertInto( 'cn_templates' )
402            ->row( [ 'tmp_name' => $this->name ] )
403            ->caller( __METHOD__ )
404            ->execute();
405        $this->id = $db->insertId();
406    }
407
408    /**
409     * Helper function to saveBannerInternal() for saving basic banner metadata
410     */
411    private function saveBasicData( IDatabase $db ) {
412        if ( $this->dirtyFlags['basic'] ) {
413            $db->newUpdateQueryBuilder()
414                ->update( 'cn_templates' )
415                ->set( [
416                    'tmp_display_anon'    => (int)$this->allocateAnon,
417                    'tmp_display_account' => (int)$this->allocateLoggedIn,
418                    'tmp_archived'        => (int)$this->archived,
419                    'tmp_category'        => $this->category,
420                    'tmp_is_template'     => (int)$this->template
421                ] )
422                ->where( [
423                    'tmp_id'              => $this->id
424                ] )
425                ->caller( __METHOD__ )
426                ->execute();
427        }
428    }
429
430    /**
431     * Get the devices that this banner should be allocated to.
432     *
433     * Array is in the form of {Device internal ID => Device header name}
434     *
435     * @return string[]
436     */
437    public function getDevices() {
438        $this->populateDeviceTargetData();
439        return $this->devices;
440    }
441
442    /**
443     * Set the devices that this banner should be allocated to.
444     *
445     * @param string[]|string $devices Header name of devices. E.g. {'android', 'desktop'}
446     *
447     * @throws BannerDataException on unknown device header name.
448     */
449    public function setDevices( $devices ): self {
450        $this->populateDeviceTargetData();
451
452        $knownDevices = CNDeviceTarget::getAvailableDevices( true );
453
454        $devices = (array)$devices;
455        $devices = array_unique( array_values( $devices ) );
456        sort( $devices );
457
458        if ( $devices != $this->devices ) {
459            $this->devices = [];
460
461            foreach ( $devices as $device ) {
462                if ( !$device ) {
463                    // Empty...
464                    continue;
465                }
466
467                if ( !array_key_exists( $device, $knownDevices ) ) {
468                    throw new BannerDataException( "Device name '$device' not known! Cannot add." );
469                }
470
471                $this->devices[$knownDevices[$device]['id']] = $device;
472            }
473            $this->markDeviceTargetDataDirty();
474        }
475
476        return $this;
477    }
478
479    /**
480     * Populates device targeting data by querying the cn_template_devices table.
481     *
482     * @see CNDeviceTarget for more information about mapping.
483     */
484    private function populateDeviceTargetData() {
485        if ( $this->dirtyFlags['devices'] !== null ) {
486            return;
487        }
488
489        $rowObj = CNDatabase::getReplicaDb()->newSelectQueryBuilder()
490            ->select( [ 'devices.dev_id', 'dev_name' ] )
491            ->from( 'cn_template_devices', 'tdev' )
492            ->join( 'cn_known_devices', 'devices', 'tdev.dev_id = devices.dev_id' )
493            ->where( [
494                'tdev.tmp_id' => $this->getId(),
495            ] )
496            ->caller( __METHOD__ )
497            ->fetchResultSet();
498
499        foreach ( $rowObj as $row ) {
500            $this->devices[ intval( $row->dev_id ) ] = $row->dev_name;
501        }
502
503        $this->markDeviceTargetDataDirty( false );
504    }
505
506    /**
507     * Sets the flag which will force saving of device targeting data on next save()
508     */
509    private function markDeviceTargetDataDirty( bool $dirty = true ) {
510        $this->dirtyFlags['devices'] = $dirty;
511    }
512
513    /**
514     * Helper function to saveBannerInternal()
515     */
516    private function saveDeviceTargetData( IDatabase $db ) {
517        if ( $this->dirtyFlags['devices'] ) {
518            // Remove all entries from the table for this banner
519            $db->newDeleteQueryBuilder()
520                ->deleteFrom( 'cn_template_devices' )
521                ->where( [ 'tmp_id' => $this->getId() ] )
522                ->caller( __METHOD__ )
523                ->execute();
524
525            // Add the new device mappings
526            if ( $this->devices ) {
527                $modifyArray = [];
528                foreach ( $this->devices as $deviceId => $deviceName ) {
529                    $modifyArray[] = [ 'tmp_id' => $this->getId(), 'dev_id' => $deviceId ];
530                }
531                $db->newInsertQueryBuilder()
532                    ->insertInto( 'cn_template_devices' )
533                    ->rows( $modifyArray )
534                    ->caller( __METHOD__ )
535                    ->execute();
536            }
537        }
538    }
539
540    /**
541     * @return array Keys are names of enabled mixins; values are mixin params.
542     * @see $wgCentralNoticeBannerMixins
543     * TODO: Remove. See T225831.
544     */
545    public function getMixins() {
546        $this->populateMixinData();
547        return $this->mixins;
548    }
549
550    /**
551     * Set the banner mixins to enable.
552     *
553     * @param array $mixins Names of mixins to enable on this banner. Valid values
554     * come from $wgCentralNoticeBannerMixins.
555     *
556     * @throws RangeException
557     * @return $this
558     */
559    public function setMixins( $mixins ): self {
560        global $wgCentralNoticeBannerMixins;
561
562        $this->populateMixinData();
563
564        $mixins = array_unique( $mixins );
565        sort( $mixins );
566
567        if ( $this->mixins != $mixins ) {
568            $this->markMixinDataDirty();
569        }
570
571        $this->mixins = [];
572        foreach ( $mixins as $mixin ) {
573            if ( !array_key_exists( $mixin, $wgCentralNoticeBannerMixins ) ) {
574                throw new RangeException( "Mixin does not exist: {$mixin}" );
575            }
576            $this->mixins[$mixin] = $wgCentralNoticeBannerMixins[$mixin];
577        }
578
579        return $this;
580    }
581
582    /**
583     * Populates mixin data from the cn_template_mixins table.
584     */
585    private function populateMixinData() {
586        global $wgCentralNoticeBannerMixins;
587
588        if ( $this->dirtyFlags['mixins'] !== null ) {
589            return;
590        }
591
592        $result = CNDatabase::getReplicaDb()->newSelectQueryBuilder()
593            ->select( 'mixin_name' )
594            ->from( 'cn_template_mixins' )
595            ->where( [
596                "tmp_id" => $this->getId(),
597            ] )
598            ->caller( __METHOD__ )
599            ->fetchResultSet();
600
601        $this->mixins = [];
602        foreach ( $result as $row ) {
603            if ( !array_key_exists( $row->mixin_name, $wgCentralNoticeBannerMixins ) ) {
604                // We only want to warn here otherwise we'd never be able to
605                // edit the banner to fix the issue! The editor should warn
606                // when a deprecated mixin is being used; but also when we
607                // do deprecate something, we should make sure nothing is using
608                // it!
609                wfLogWarning(
610                    "Mixin does not exist: {$row->mixin_name}, included from banner {$this->name}"
611                );
612            }
613            $this->mixins[$row->mixin_name] = $wgCentralNoticeBannerMixins[$row->mixin_name];
614        }
615
616        $this->markMixinDataDirty( false );
617    }
618
619    /**
620     * Sets the flag which will force saving of mixin data upon next save()
621     * @param bool $dirty
622     */
623    private function markMixinDataDirty( bool $dirty = true ) {
624        $this->dirtyFlags['mixins'] = $dirty;
625    }
626
627    private function saveMixinData( IDatabase $db ) {
628        if ( $this->dirtyFlags['mixins'] ) {
629            $db->newDeleteQueryBuilder()
630                ->deleteFrom( 'cn_template_mixins' )
631                ->where( [ 'tmp_id' => $this->getId() ] )
632                ->caller( __METHOD__ )
633                ->execute();
634
635            foreach ( $this->mixins as $name => $params ) {
636                $name = trim( $name );
637                if ( !$name ) {
638                    continue;
639                }
640                $db->newInsertQueryBuilder()
641                    ->insertInto( 'cn_template_mixins' )
642                    ->row( [
643                        'tmp_id' => $this->getId(),
644                        // TODO: What were we going to use this for again?
645                        'page_id' => 0,
646                        'mixin_name' => $name,
647                    ] )
648                    ->caller( __METHOD__ )
649                    ->execute();
650            }
651        }
652    }
653
654    /**
655     * Returns language codes that are considered a priority for translations.
656     *
657     * If a language is in this list, it means that the translation UI will promote
658     * translating them, and discourage translating other languages.
659     *
660     * @return string[]
661     */
662    public function getPriorityLanguages() {
663        $this->populatePriorityLanguageData();
664        return $this->priorityLanguages;
665    }
666
667    /**
668     * Set language codes that should be considered a priority for translation.
669     *
670     * If a language is in this list, it means that the translation UI will promote
671     * translating them, and discourage translating other languages.
672     *
673     * @param string[] $languageCodes
674     *
675     * @return $this
676     */
677    public function setPriorityLanguages( $languageCodes ) {
678        $this->populatePriorityLanguageData();
679
680        $languageCodes = array_unique( (array)$languageCodes );
681        sort( $languageCodes );
682
683        if ( $this->priorityLanguages != $languageCodes ) {
684            $this->priorityLanguages = $languageCodes;
685            $this->markPriorityLanguageDataDirty();
686        }
687
688        return $this;
689    }
690
691    private function populatePriorityLanguageData() {
692        global $wgNoticeUseTranslateExtension;
693
694        if ( $this->dirtyFlags['prioritylang'] !== null ) {
695            return;
696        }
697
698        if ( $wgNoticeUseTranslateExtension ) {
699            $services = Services::getInstance();
700            $langs = $services->getMessageGroupMetadata()->get(
701                BannerMessageGroup::getTranslateGroupName( $this->getName() ),
702                'prioritylangs'
703            );
704            if ( !$langs ) {
705                // If priority langs is not set; MessageGroupMetadata::get will return false
706                $langs = '';
707            }
708            $this->priorityLanguages = explode( ',', $langs );
709        }
710        $this->markPriorityLanguageDataDirty( false );
711    }
712
713    private function markPriorityLanguageDataDirty( bool $dirty = true ) {
714        $this->dirtyFlags['prioritylang'] = $dirty;
715    }
716
717    private function savePriorityLanguageData() {
718        global $wgNoticeUseTranslateExtension;
719
720        if ( !$wgNoticeUseTranslateExtension || !$this->dirtyFlags['prioritylang'] ) {
721            return;
722        }
723        $groupName = BannerMessageGroup::getTranslateGroupName( $this->getName() );
724
725        $services = Services::getInstance();
726        $messageGroupMetadata = $services->getMessageGroupMetadata();
727
728        if ( $this->priorityLanguages === [] ) {
729            // Using false to delete the value instead of writing empty content
730            $messageGroupMetadata->set( $groupName, 'prioritylangs', false );
731        } else {
732            $messageGroupMetadata->set(
733                $groupName,
734                'prioritylangs',
735                implode( ',', $this->priorityLanguages )
736            );
737        }
738    }
739
740    public function getDbKey(): string {
741        return "Centralnotice-template-{$this->getName()}";
742    }
743
744    public function getTitle(): Title {
745        return Title::makeTitle( NS_MEDIAWIKI, $this->getDbKey() );
746    }
747
748    /**
749     * Return the names of campaigns that this banner is currently used in.
750     *
751     * @return string[]
752     */
753    public function getCampaignNames() {
754        $result = CNDatabase::getReplicaDb()->newSelectQueryBuilder()
755            ->select( 'notices.not_name' )
756            ->from( 'cn_notices', 'notices' )
757            ->join( 'cn_assignments', 'assignments', 'notices.not_id = assignments.not_id' )
758            ->where( [
759                'assignments.tmp_id' => $this->getId(),
760            ] )
761            ->caller( __METHOD__ )
762            ->fetchResultSet();
763
764        $campaigns = [];
765        foreach ( $result as $row ) {
766            $campaigns[] = $row->not_name;
767        }
768
769        return $campaigns;
770    }
771
772    /**
773     * Returns an array of Title objects that have been included as templates
774     * in this banner.
775     *
776     * @return Title[]
777     */
778    public function getIncludedTemplates() {
779        return $this->getTitle()->getTemplateLinksFrom();
780    }
781
782    /**
783     * Get the raw body HTML for the banner.
784     */
785    public function getBodyContent(): string {
786        $this->populateBodyContent();
787        return $this->bodyContent;
788    }
789
790    /**
791     * Set the raw body HTML for the banner.
792     */
793    public function setBodyContent( string $text ): self {
794        $this->populateBodyContent();
795
796        if ( $this->bodyContent !== $text ) {
797            $this->bodyContent = $text;
798            $this->markBodyContentDirty();
799        }
800
801        return $this;
802    }
803
804    /**
805     * @throws BannerContentException
806     */
807    private function populateBodyContent() {
808        if ( $this->dirtyFlags['content'] !== null ) {
809            return;
810        }
811
812        $curRev = MediaWikiServices::getInstance()
813            ->getRevisionLookup()
814            ->getRevisionByTitle( $this->getTitle() );
815        if ( !$curRev ) {
816            throw new BannerContentException( "No content for banner: {$this->name}" );
817        }
818
819        $content = $curRev->getContent( SlotRecord::MAIN );
820        $this->bodyContent = ( $content instanceof TextContent ) ? $content->getText() : null;
821
822        $this->markBodyContentDirty( false );
823    }
824
825    /**
826     * @param bool $dirty If true, we're storing a flag that means that the
827     *  in-memory banner content is newer than what's stored in the database.
828     *  If false, we're clearing that bit.
829     */
830    private function markBodyContentDirty( $dirty = true ) {
831        $this->dirtyFlags['content'] = $dirty;
832    }
833
834    private function saveBodyContent( ?string $summary, User $user ) {
835        global $wgNoticeUseTranslateExtension;
836
837        if ( !$this->dirtyFlags['content'] ) {
838            return;
839        }
840        $wikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $this->getTitle() );
841
842        $summary ??= '';
843
844        $contentObj = ContentHandler::makeContent( $this->bodyContent, $wikiPage->getTitle() );
845
846        $tags = [ 'centralnotice' ];
847        $pageResult = $wikiPage->doUserEditContent(
848            $contentObj,
849            $user,
850            $summary,
851            EDIT_FORCE_BOT,
852            false,
853            $tags
854        );
855
856        self::protectBannerContent( $wikiPage, $user );
857
858        // Get the revision and page ID of the page that was created/modified
859        if ( $wgNoticeUseTranslateExtension && $pageResult->value['revision-record'] ) {
860            $revisionRecord = $pageResult->value['revision-record'];
861            $revisionId = $revisionRecord->getId();
862            $pageId = $revisionRecord->getPageId();
863
864            // If the banner includes translatable messages, tag it for translation
865            $fields = $this->extractMessageFields();
866            if ( count( $fields ) > 0 ) {
867                // Tag the banner for translation
868                self::addTag( self::TRANSLATE_BANNER_TAG, $revisionId, $pageId, (string)$this->getId() );
869                $this->runTranslateJob = true;
870            }
871            $this->invalidateCache( $fields );
872        }
873    }
874
875    public function getMessageField( string $field_name ): BannerMessage {
876        return new BannerMessage( $this->getName(), $field_name );
877    }
878
879    /**
880     * Returns all the message fields in a banner
881     *
882     * Check the cache first, then calculate if necessary.  Will always prefer the cache.
883     * @see Banner::extractMessageFields()
884     *
885     * @return array
886     */
887    public function getMessageFieldsFromCache() {
888        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
889        $key = $this->getMessageFieldsCacheKey( $cache );
890
891        return $cache->getWithSetCallback(
892            $key,
893            $cache::TTL_MONTH,
894            $this->extractMessageFields( ... ),
895            [ 'checkKeys' => [ $key ], 'lockTSE' => 60 ]
896        );
897    }
898
899    /**
900     * @param array|null $newFields Optional new result of the extractMessageFields()
901     */
902    public function invalidateCache( $newFields = null ) {
903        // Update cache after the DB transaction finishes
904        CNDatabase::getPrimaryDb()->onTransactionCommitOrIdle(
905            function () use ( $newFields ) {
906                $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
907                $key = $this->getMessageFieldsCacheKey( $cache );
908
909                $cache->touchCheckKey( $key );
910                if ( $newFields !== null ) {
911                    // May as well set the new volatile cache value in the local datacenter
912                    $cache->set( $key, $newFields, $cache::TTL_MONTH );
913                }
914            },
915            __METHOD__
916        );
917    }
918
919    /**
920     * @param WANObjectCache $cache
921     * @return mixed
922     */
923    private function getMessageFieldsCacheKey( $cache ) {
924        return $cache->makeKey( 'centralnotice', 'bannerfields', $this->getName() );
925    }
926
927    /**
928     * Extract the raw fields and field names from the banner body source.
929     *
930     * Always recalculate.  If you want the cached value, please use getMessageFieldsFromCache.
931     *
932     * @return array
933     */
934    public function extractMessageFields() {
935        $parser = MediaWikiServices::getInstance()->getParser();
936
937        $popts = ParserOptions::newFromContext( RequestContext::getMain() );
938        $expanded = $parser->parse(
939            $this->getBodyContent(), $this->getTitle(),
940            $popts
941        )->runOutputPipeline( $popts, [] )->getContentHolderText();
942
943        // Also search the preload js for fields.
944        $renderer = new BannerRenderer( RequestContext::getMain(), $this );
945        $expanded .= $renderer->getPreloadJsRaw();
946
947        // Extract message fields from the banner body
948        $fields = [];
949        $allowedChars = Title::legalChars();
950        // Janky custom syntax to pass arguments to a message, broken and unused:
951        // "{{{fieldname:arg1|arg2}}}". This is why ':' is forbidden. FIXME: Remove.
952        // Also forbid ',' so we can use it as a separator between banner name and
953        // message name in banner content, and thus insert messages from other banners.
954        // ' ' is forbidden to avoid problems in the UI.
955        $allowedChars = str_replace( [ ':', ',', ' ' ], '', $allowedChars );
956        preg_match_all( "/{{{([$allowedChars]+)(:[^}]*)?}}}/u", $expanded, $fields );
957
958        // Remove duplicate keys and count occurrences
959        $unique_fields = array_unique( array_flip( $fields[1] ) );
960        $fields = array_intersect_key( array_count_values( $fields[1] ), $unique_fields );
961
962        return array_diff_key( $fields, array_flip( $renderer->getMagicWords() ) );
963    }
964
965    /**
966     * Returns a list of messages that are either published or in the CNBanner translation
967     *
968     * @param bool $inTranslation If true and using group translation, this will return
969     * all the messages that are in the translation system
970     *
971     * @return array A list of languages with existing field translations
972     */
973    public function getAvailableLanguages( bool $inTranslation = false ) {
974        global $wgLanguageCode;
975        $availableLangs = [];
976
977        // Bit of an ugly hack to get just the banner prefix
978        $prefix = $this->getMessageField( '' )
979            ->getDbKey( null, $inTranslation ? NS_CN_BANNER : NS_MEDIAWIKI );
980
981        $db = CNDatabase::getReplicaDb();
982        $result = $db->newSelectQueryBuilder()
983            ->select( 'page_title' )
984            ->from( 'page' )
985            ->where( [
986                'page_namespace' => $inTranslation ? NS_CN_BANNER : NS_MEDIAWIKI,
987                $db->expr( 'page_title', IExpression::LIKE, new LikeValue( $prefix, $db->anyString() ) ),
988            ] )
989            ->caller( __METHOD__ )
990            ->fetchResultSet();
991        foreach ( $result as $row ) {
992            if (
993                preg_match(
994                    "/\Q{$prefix}\E([^\/]+)(?:\/([a-z_]+))?/", $row->page_title,
995                    $matches
996                )
997            ) {
998                $lang = $matches[2] ?? $wgLanguageCode;
999                $availableLangs[$lang] = true;
1000            }
1001        }
1002        return array_keys( $availableLangs );
1003    }
1004
1005    /**
1006     * Saves any changes made to the banner object into the database
1007     *
1008     * @param User $user
1009     * @param string|null $summary Summary (comment) to associate with all changes,
1010     *   including banner content and messages (which are implemented as wiki
1011     *   pages).
1012     *
1013     * @return $this
1014     * @throws Exception
1015     */
1016    public function save( User $user, ?string $summary = null ) {
1017        $db = CNDatabase::getPrimaryDb();
1018        $action = 'modified';
1019
1020        // Don't move this to saveBannerInternal--can't be in a transaction
1021        // TODO: explain why not. Is text in another database?
1022        $this->saveBodyContent( $summary, $user );
1023
1024        // Open a transaction so that everything is consistent
1025        $db->startAtomic( __METHOD__ );
1026
1027        if ( !$this->exists( true ) ) {
1028            $action = 'created';
1029            $this->initializeDbForNewBanner( $db );
1030        }
1031        $this->saveBannerInternal( $db );
1032        $this->logBannerChange( $action, $user, $summary );
1033
1034        $db->endAtomic( __METHOD__ );
1035
1036        // Clear the dirty flags
1037        foreach ( $this->dirtyFlags as &$value ) {
1038            $value = false;
1039        }
1040        unset( $value );
1041
1042        if ( $this->runTranslateJob ) {
1043            // Must be run after banner has finished saving due to some dependencies that
1044            // exist in the render job.
1045            // TODO: This will go away if we start tracking messages in database :)
1046            MessageGroups::singleton()->recache();
1047            MediaWikiServices::getInstance()->getJobQueueGroup()->push(
1048                RebuildMessageIndexJob::newJob()
1049            );
1050            $this->runTranslateJob = false;
1051        }
1052
1053        return $this;
1054    }
1055
1056    /**
1057     * Called before saveBannerInternal() when a new to the database banner is
1058     * being saved. Intended to create all table rows required such that any
1059     * additional operation can be an UPDATE statement.
1060     */
1061    private function initializeDbForNewBanner( IDatabase $db ) {
1062        $this->initializeDbBasicData( $db );
1063    }
1064
1065    /**
1066     * Helper function to save(). This is wrapped in a database transaction and
1067     * is intended to be easy to override -- though overriding function should
1068     * call this at some point. :)
1069     *
1070     * Because it is wrapped in a database transaction; most MediaWiki calls
1071     * like page saving cannot be performed here.
1072     *
1073     * Dirty flags are not globally reset until after this function is called.
1074     *
1075     * @param IDatabase $db
1076     *
1077     * @throws BannerExistenceException
1078     */
1079    private function saveBannerInternal( IDatabase $db ) {
1080        $this->saveBasicData( $db );
1081        $this->saveDeviceTargetData( $db );
1082        $this->saveMixinData( $db );
1083        $this->savePriorityLanguageData();
1084    }
1085
1086    /**
1087     * Archive a banner.
1088     *
1089     * TODO: Remove data from translation, in place replace all templates
1090     *
1091     * @return $this
1092     */
1093    public function archive() {
1094        if ( $this->dirtyFlags['basic'] === null ) {
1095            $this->populateBasicData();
1096        }
1097        $this->dirtyFlags['basic'] = true;
1098
1099        $this->archived = true;
1100
1101        return $this;
1102    }
1103
1104    /**
1105     * @throws BannerDataException
1106     * @throws BannerExistenceException
1107     */
1108    public function cloneBanner( string $destination, User $user, ?string $summary = null ): self {
1109        if ( !self::isValidBannerName( $destination ) ) {
1110            throw new BannerDataException( "Banner name must be in format /^[A-Za-z0-9_]+$/" );
1111        }
1112
1113        $destBanner = self::newFromName( $destination );
1114        if ( $destBanner->exists() ) {
1115            throw new BannerExistenceException( "Banner by that name already exists!" );
1116        }
1117
1118        $destBanner->setAllocation( $this->allocateToAnon(), $this->allocateToLoggedIn() );
1119        $destBanner->setCategory( $this->getCategory() );
1120        $destBanner->setDevices( $this->getDevices() );
1121        $destBanner->setMixins( array_keys( $this->getMixins() ) );
1122        $destBanner->setPriorityLanguages( $this->getPriorityLanguages() );
1123
1124        $destBanner->setBodyContent( $this->getBodyContent() );
1125
1126        // Populate the message fields
1127        $langs = $this->getAvailableLanguages();
1128        $fields = $this->extractMessageFields();
1129        foreach ( $langs as $lang ) {
1130            foreach ( $fields as $field => $count ) {
1131                $text = $this->getMessageField( $field )->getContents( $lang );
1132                if ( $text !== null ) {
1133                    $destBanner->getMessageField( $field )
1134                        ->update( $text, $lang, $user, $summary );
1135                }
1136            }
1137        }
1138
1139        // Save it!
1140        $destBanner->save( $user, $summary );
1141        $this->invalidateCache( $fields );
1142
1143        return $destBanner;
1144    }
1145
1146    public function remove( User $user ) {
1147        self::removeBanner( $this->getName(), $user );
1148    }
1149
1150    public static function removeBanner( string $name, User $user, ?string $summary = null ) {
1151        global $wgNoticeUseTranslateExtension;
1152
1153        $bannerObj = self::fromName( $name );
1154        $id = $bannerObj->getId();
1155        $res = CNDatabase::getReplicaDb()->newSelectQueryBuilder()
1156            ->select( 'asn_id' )
1157            ->from( 'cn_assignments' )
1158            ->where( [ 'tmp_id' => $id ] )
1159            ->caller( __METHOD__ )
1160            ->fetchResultSet();
1161
1162        if ( $res->numRows() > 0 ) {
1163            throw new LogicException( 'Cannot remove a template still bound to a campaign!' );
1164        }
1165
1166        // Log the removal of the banner
1167        // FIXME: this log line will display changes with inverted sense
1168        $bannerObj->logBannerChange( 'removed', $user, $summary );
1169
1170        // Delete banner record from the CentralNotice cn_templates table
1171        $dbw = CNDatabase::getPrimaryDb();
1172        $dbw->newDeleteQueryBuilder()
1173            ->deleteFrom( 'cn_templates' )
1174            ->where( [ 'tmp_id' => $id ] )
1175            ->caller( __METHOD__ )
1176            ->execute();
1177
1178        // Delete the MediaWiki page that contains the banner source
1179        // TODO Inconsistency: deletion of banner content is not recorded
1180        // as a bot edit, so it does not appear on the CN logs page. Also,
1181        // related messages are not deleted.
1182        $wikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $bannerObj->getTitle() );
1183        $wikiPage->doDeleteArticleReal( $summary ?: '', $user );
1184
1185        if ( $wgNoticeUseTranslateExtension ) {
1186            // Remove any revision tags related to the banner
1187            self::removeTag( self::TRANSLATE_BANNER_TAG, $wikiPage->getId() );
1188
1189            $services = Services::getInstance();
1190            // Add the preferred language metadata if it exists
1191
1192            $services->getMessageGroupMetadata()->set(
1193                BannerMessageGroup::getTranslateGroupName( $name ),
1194                'prioritylangs',
1195                false
1196            );
1197        }
1198    }
1199
1200    /**
1201     * Add a revision tag for the banner
1202     * @param string $tag The name of the tag
1203     * @param int $revisionId ID of the revision
1204     * @param int $pageId ID of the MediaWiki page for the banner
1205     * @param string $bannerId ID of banner this revtag belongs to
1206     * @throws LogicException
1207     */
1208    public static function addTag( $tag, $revisionId, $pageId, $bannerId ) {
1209        if ( is_object( $revisionId ) ) {
1210            throw new LogicException( 'Got object, excepted id' );
1211        }
1212
1213        // There should only ever be one tag applied to a banner object
1214        self::removeTag( $tag, $pageId );
1215
1216        $conds = [
1217            'rt_page' => $pageId,
1218            'rt_type' => $tag,
1219            'rt_revision' => $revisionId
1220        ];
1221
1222        if ( $bannerId !== null ) {
1223            $conds['rt_value'] = $bannerId;
1224        }
1225
1226        CNDatabase::getPrimaryDb()->newInsertQueryBuilder()
1227            ->insertInto( 'revtag' )
1228            ->row( $conds )
1229            ->caller( __METHOD__ )
1230            ->execute();
1231    }
1232
1233    /**
1234     * Make sure the banner is not tagged with the specified tag
1235     * @param string $tag The name of the tag
1236     * @param int $pageId ID of the MediaWiki page for the banner
1237     * @throws Exception
1238     */
1239    private static function removeTag( $tag, $pageId ) {
1240        CNDatabase::getPrimaryDb()->newDeleteQueryBuilder()
1241            ->deleteFrom( 'revtag' )
1242            ->where( [
1243                'rt_page' => $pageId,
1244                'rt_type' => $tag
1245            ] )
1246            ->caller( __METHOD__ )
1247            ->execute();
1248    }
1249
1250    /**
1251     * Given one or more campaign ids, return all banners bound to them
1252     *
1253     * @param int|array $campaigns list of campaign numeric IDs
1254     * @param bool $fromPrimary Whether to use the primary database or the replica database
1255     *
1256     * @return array a 2D array of banners with associated weights and settings
1257     */
1258    public static function getCampaignBanners( $campaigns, bool $fromPrimary = false ) {
1259        if ( !$campaigns ) {
1260            return [];
1261        }
1262
1263        $db = $fromPrimary ? CNDatabase::getPrimaryDb() : CNDatabase::getReplicaDb();
1264        $banners = [];
1265
1266        $res = $db->newSelectQueryBuilder()
1267            // Aliases (keys) are needed to avoid problems with table prefixes
1268            ->select( [
1269                'tmp_name',
1270                'tmp_weight',
1271                'tmp_display_anon',
1272                'tmp_display_account',
1273                'tmp_category',
1274                'not_name',
1275                'not_preferred',
1276                'asn_bucket',
1277                'not_buckets',
1278                'not_throttle',
1279                'dev_name',
1280            ] )->from(
1281                'cn_notices',
1282                'notices'
1283            )->join(
1284                'cn_assignments',
1285                'assignments',
1286                'notices.not_id = assignments.not_id'
1287            )->join(
1288                'cn_templates',
1289                'templates',
1290                'assignments.tmp_id = templates.tmp_id'
1291            )->leftJoin(
1292                'cn_template_devices',
1293                'template_devices',
1294                'template_devices.tmp_id = assignments.tmp_id'
1295            )->join(
1296                'cn_known_devices',
1297                'known_devices',
1298                'known_devices.dev_id = template_devices.dev_id'
1299            )->where(
1300                [
1301                    'notices.not_id' => $campaigns,
1302                ]
1303            )->caller( __METHOD__ )->fetchResultSet();
1304
1305        foreach ( $res as $row ) {
1306            $banners[] = [
1307                // name of the banner
1308                'name' => $row->tmp_name,
1309                // weight assigned to the banner
1310                'weight' => (int)$row->tmp_weight,
1311                // display to anonymous users?
1312                'display_anon' => (int)$row->tmp_display_anon,
1313                // display to logged in users?
1314                'display_account' => (int)$row->tmp_display_account,
1315                // fundraising banner?
1316                'fundraising' => (int)( $row->tmp_category === 'fundraising' ),
1317                // device this banner can target
1318                'device' => $row->dev_name,
1319                // campaign the banner is assigned to
1320                'campaign' => $row->not_name,
1321                // z level of the campaign
1322                'campaign_z_index' => $row->not_preferred,
1323                'campaign_num_buckets' => (int)$row->not_buckets,
1324                'campaign_throttle' => (int)$row->not_throttle,
1325                'bucket' => ( (int)$row->not_buckets === 1 ) ? 0 : (int)$row->asn_bucket,
1326            ];
1327        }
1328
1329        return $banners;
1330    }
1331
1332    /**
1333     * Return settings for a banner
1334     *
1335     * @param string $bannerName name of banner
1336     * @param bool $detailed if true, get some more expensive info
1337     * @param bool $fromPrimary Whether to get the Banner Settings from the Primary DB
1338     *
1339     * @return array an array of banner settings
1340     * @throws RangeException
1341     */
1342    public static function getBannerSettings( string $bannerName, bool $detailed = true, bool $fromPrimary = false ) {
1343        $banner = self::fromName( $bannerName );
1344        if ( !$banner->exists( $fromPrimary ) ) {
1345            throw new RangeException( "Banner doesn't exist!" );
1346        }
1347
1348        $details = [
1349            'anon'             => (int)$banner->allocateToAnon( $fromPrimary ),
1350            'account'          => (int)$banner->allocateToLoggedIn(),
1351            // TODO: Death to this!
1352            'fundraising'      => (int)( $banner->getCategory() === 'fundraising' ),
1353            'category'         => $banner->getCategory(),
1354            'controller_mixin' => implode( ",", array_keys( $banner->getMixins() ) ),
1355            'devices'          => array_values( $banner->getDevices() ),
1356        ];
1357
1358        if ( $detailed ) {
1359            $details['prioritylangs'] = $banner->getPriorityLanguages();
1360        }
1361
1362        return $details;
1363    }
1364
1365    /**
1366     * FIXME: a little thin, it's just enough to get the job done
1367     *
1368     * @param string $name
1369     * @param int $ts
1370     * @return array|null banner settings as an associative array, with these properties:
1371     *    display_anon: 0/1 whether the banner is displayed to anonymous users
1372     *    display_account: 0/1 same, for logged-in users
1373     *    fundraising: 0/1, is in the fundraising group
1374     *    device: device key
1375     */
1376    public static function getHistoricalBanner( string $name, int $ts ) {
1377        $id = self::fromName( $name )->getId();
1378
1379        $dbr = CNDatabase::getReplicaDb();
1380
1381        $newestLog = $dbr->newSelectQueryBuilder()
1382            ->select( [
1383                "log_id" => "MAX(tmplog_id)",
1384            ] )
1385            ->from( 'cn_template_log' )
1386            ->where( [
1387                $dbr->expr( 'tmplog_timestamp', '<=', $dbr->timestamp( $ts ) ),
1388                'tmplog_template_id' => $id,
1389            ] )
1390            ->caller( __METHOD__ )
1391            ->fetchRow();
1392
1393        if ( $newestLog->log_id === null ) {
1394            return null;
1395        }
1396
1397        $row = $dbr->newSelectQueryBuilder()
1398            ->select( [
1399                "display_anon" => "tmplog_end_anon",
1400                "display_account" => "tmplog_end_account",
1401                "fundraising" => "tmplog_end_fundraising",
1402            ] )
1403            ->from( 'cn_template_log' )
1404            ->where( [
1405                "tmplog_id" => $newestLog->log_id,
1406            ] )
1407            ->caller( __METHOD__ )
1408            ->fetchRow();
1409
1410        return [
1411            'display_anon' => (int)$row->display_anon,
1412            'display_account' => (int)$row->display_account,
1413            'fundraising' => (int)$row->fundraising,
1414            'devices' => [ 'desktop' ],
1415        ];
1416    }
1417
1418    /**
1419     * @param string $name
1420     * @param User $user
1421     * @param self $template
1422     * @param string|null $summary
1423     * @return string|null error message key or null on success
1424     * @throws BannerDataException
1425     * @throws BannerExistenceException
1426     */
1427    public static function addFromBannerTemplate(
1428        string $name, User $user, Banner $template, ?string $summary = null
1429    ) {
1430        if ( !$template->isTemplate() ) {
1431            return 'centralnotice-banner-template-error';
1432        }
1433        return static::addBanner(
1434            $name, $template->getBodyContent(), $user,
1435            $template->allocateToAnon(),
1436            $template->allocateToLoggedIn(),
1437            $template->getMixins(),
1438            $template->getPriorityLanguages(),
1439            $template->getDevices(),
1440            $summary,
1441            false,
1442            $template->getCategory()
1443        );
1444    }
1445
1446    /**
1447     * Create a new banner
1448     *
1449     * @param string $name name of banner
1450     * @param string $body content of banner
1451     * @param User $user User causing the change
1452     * @param bool $displayAnon flag for display to anonymous users
1453     * @param bool $displayAccount flag for display to logged in users
1454     * @param array $mixins list of mixins (optional)
1455     * @param array $priorityLangs Array of priority languages for the Translate extension
1456     * @param array|null $devices Array of device names this banner is targeted at
1457     * @param string|null $summary Optional summary of changes for logging
1458     * @param bool $isTemplate Is the banner marked as a template?
1459     * @param string|null $category Category of the banner
1460     *
1461     * @return string|null error message key or null on success
1462     * @throws BannerDataException
1463     */
1464    public static function addBanner(
1465        string $name,
1466        string $body,
1467        User $user,
1468        bool $displayAnon,
1469        bool $displayAccount,
1470        $mixins = [],
1471        $priorityLangs = [],
1472        $devices = null,
1473        ?string $summary = null,
1474        bool $isTemplate = false,
1475        ?string $category = null
1476    ) {
1477        // Default initial value for devices
1478        $devices ??= [ 'desktop' ];
1479
1480        // Set default value for category
1481        if ( !$category ) {
1482            $category = '{{{campaign}}}';
1483        }
1484
1485        if ( $name === '' || !self::isValidBannerName( $name ) || $body === '' ) {
1486            return 'centralnotice-null-string';
1487        }
1488
1489        $banner = self::newFromName( $name );
1490        if ( $banner->exists() ) {
1491            return 'centralnotice-template-exists';
1492        }
1493
1494        $banner->setAllocation( $displayAnon, $displayAccount );
1495        $banner->setCategory( $category );
1496        $banner->setDevices( $devices );
1497        $banner->setPriorityLanguages( $priorityLangs );
1498        $banner->setBodyContent( $body );
1499        $banner->setIsTemplate( $isTemplate );
1500
1501        $banner->setMixins( $mixins );
1502
1503        $banner->save( $user, $summary );
1504        return null;
1505    }
1506
1507    /**
1508     * Log setting changes related to a banner
1509     *
1510     * @param string $action 'created', 'modified', or 'removed'
1511     * @param User $user The user causing the change
1512     * @param string|null $summary Summary (comment) for this action
1513     */
1514    public function logBannerChange( string $action, User $user, ?string $summary = null ) {
1515        ChoiceDataProvider::invalidateCache();
1516
1517        $endSettings = [];
1518        if ( $action !== 'removed' ) {
1519            $endSettings = self::getBannerSettings( $this->getName(), true, true );
1520        }
1521
1522        $dbw = CNDatabase::getPrimaryDb();
1523
1524        $log = [
1525            'tmplog_timestamp'     => $dbw->timestamp(),
1526            'tmplog_user_id'       => $user->getId(),
1527            'tmplog_action'        => $action,
1528            'tmplog_template_id'   => $this->getId(),
1529            'tmplog_template_name' => $this->getName(),
1530            'tmplog_content_change' => (int)$this->dirtyFlags['content'],
1531            // Summary shouldn't come in null, but just in case...
1532            'tmplog_comment'       => $summary ?? '',
1533        ];
1534
1535        foreach ( $endSettings as $key => $value ) {
1536            if ( is_array( $value ) ) {
1537                $value = FormatJson::encode( $value );
1538            }
1539
1540            $log[ 'tmplog_end_' . $key ] = $value;
1541        }
1542
1543        $dbw->newInsertQueryBuilder()
1544            ->insertInto( 'cn_template_log' )
1545            ->row( $log )
1546            ->caller( __METHOD__ )
1547            ->execute();
1548    }
1549
1550    /**
1551     * Validation function for banner names. Will return true iff the name fits
1552     * the generic format of letters, numbers, and dashes.
1553     */
1554    public static function isValidBannerName( string $name ): bool {
1555        // Note: regex should coordinate with banner name validation
1556        // in ext.centralNotice.adminUi.bannerSequence.js
1557        return (bool)preg_match( '/^[A-Za-z0-9_]{1,230}$/', $name );
1558    }
1559
1560    /**
1561     * Check to see if a banner actually exists in the database
1562     *
1563     * @throws BannerDataException If it's a silly query
1564     */
1565    public function exists( bool $fromPrimary = false ): bool {
1566        $db = $fromPrimary ? CNDatabase::getPrimaryDb() : CNDatabase::getReplicaDb();
1567        if ( $this->name !== null ) {
1568            $selector = [ 'tmp_name' => $this->name ];
1569        } elseif ( $this->id !== null ) {
1570            $selector = [ 'tmp_id' => $this->id ];
1571        } else {
1572            throw new BannerDataException(
1573                'Cannot determine banner existence without name or ID.'
1574            );
1575        }
1576        return (bool)$db->newSelectQueryBuilder()
1577            ->select( 'tmp_name' )
1578            ->from( 'cn_templates' )
1579            ->where( $selector )
1580            ->caller( __METHOD__ )
1581            ->fetchRow();
1582    }
1583
1584    /**
1585     * Get BannerMessage defined in a specific banner.
1586     */
1587    public static function getMessageFieldForBanner( string $bannerName, string $messageName ): BannerMessage {
1588        return new BannerMessage( $bannerName, $messageName );
1589    }
1590
1591    /**
1592     * Apply cascading protection to the banner template or message
1593     *
1594     * @param WikiPage $wikiPage
1595     * @param User $user
1596     * @param bool $isTranslatedMessage if true, protect with $wgCentralNoticeMessageProtectRight
1597     * @throws BannerContentException
1598     */
1599    public static function protectBannerContent(
1600        WikiPage $wikiPage, User $user, bool $isTranslatedMessage = false
1601    ) {
1602        if ( $isTranslatedMessage ) {
1603            global $wgCentralNoticeMessageProtectRight;
1604            if ( $wgCentralNoticeMessageProtectRight === '' ) {
1605                return;
1606            }
1607            $protectionRight = $wgCentralNoticeMessageProtectRight;
1608        } else {
1609            $protectionRight = 'centralnotice-admin';
1610        }
1611
1612        $limits = [
1613            'edit' => $protectionRight,
1614            'move' => $protectionRight,
1615        ];
1616
1617        $expiry = [
1618            'edit' => 'infinity',
1619            'move' => 'infinity',
1620        ];
1621
1622        $cascade = true;
1623        $reason = wfMessage( 'centralnotice-banner-protection-log-reason' )
1624            ->inContentLanguage()->text();
1625
1626        $status = $wikiPage->doUpdateRestrictions(
1627            $limits, $expiry, $cascade, $reason, $user
1628        );
1629        if ( !$cascade || !$status->isGood() ) {
1630            throw new BannerContentException(
1631                'Unable to protect banner' . $status->getMessage()->text()
1632            );
1633        }
1634    }
1635}