Page MenuHomeWickedGov Phorge

Banner.php
No OneTemporary

Size
46 KB
Referenced Files
None
Subscribers
None

Banner.php

<?php
/**
* This file is part of the CentralNotice Extension to MediaWiki
* https://www.mediawiki.org/wiki/Extension:CentralNotice
*
* @section LICENSE
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\TextContent;
use MediaWiki\Context\RequestContext;
use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
use MediaWiki\Extension\Translate\MessageLoading\RebuildMessageIndexJob;
use MediaWiki\Extension\Translate\Services;
use MediaWiki\Json\FormatJson;
use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IExpression;
use Wikimedia\Rdbms\LikeValue;
/**
* CentralNotice banner object. Banners are pieces of rendered wikimarkup
* injected as HTML onto MediaWiki pages via the sitenotice hook.
*
* - They are allowed to be specific to devices and user status.
* - They allow 'mixins', pieces of javascript that add additional standard
* functionality to the banner.
* - They have a concept of 'messages' which are translatable strings marked
* out by {{{name}}} in the banner body.
*
* @see BannerMessage
* @see BannerRenderer
* @see BannerMixin
*/
class Banner {
/** Indicates a revision of a banner that can be translated. */
public const TRANSLATE_BANNER_TAG = 'banner:translate';
/**
* Keys indicate a group of properties (which should be a 1-to-1 match to
* a database table.) If the value is null it means the data is not yet
* loaded. True means the data is clean and not modified. False means the
* data should be saved on the next call to save().
*
* Most functions should only ever set the flag to true; flags will be
* reset to false in save().
*
* @var (null|bool)[]
*/
private $dirtyFlags = [
'content' => null,
'messages' => null,
'basic' => null,
'devices' => null,
'mixins' => null,
'prioritylang' => null,
];
/** @var int Unique database identifier key. */
private $id = null;
/** @var string Unique human friendly name of banner. */
private $name = null;
/** @var bool True if the banner should be allocated to anonymous users. */
private $allocateAnon = false;
/** @var bool True if the banner should be allocated to logged in users. */
private $allocateLoggedIn = false;
/** @var string Category that the banner belongs to. Will be special value expanded. */
private $category = '{{{campaign}}}';
/** @var bool True if archived and hidden from default view. */
private $archived = false;
/** @var string[] Devices this banner should be allocated to in the form
* {Device ID => Device header name}
*/
private $devices = [];
/** @var string[] Names of enabled mixins */
private $mixins = [];
/** @var string[] Language codes considered a priority for translation. */
private $priorityLanguages = [];
/** @var string Wikitext content of the banner */
private $bodyContent = '';
/** @var bool */
private $runTranslateJob = false;
/** @var bool Is banner meant to be used as a template for other banners */
private $template = false;
/**
* Create a banner object from a known ID. Must already be
* an object in the database. If a fully new banner is to be created
* use @see newFromName().
*
* @param int $id Unique database ID of the banner
*
* @return self
*/
public static function fromId( $id ) {
$obj = new self();
$obj->id = $id;
return $obj;
}
/**
* Create a banner object from a known banner name. Must already be
* an object in the database. If a fully new banner is to be created
* use @see newFromName().
*
* @param string $name
*
* @return self
* @throws BannerDataException
*/
public static function fromName( $name ) {
if ( !self::isValidBannerName( $name ) ) {
throw new BannerDataException( "Invalid banner name supplied." );
}
$obj = new self();
$obj->name = $name;
return $obj;
}
/**
* Create a brand new banner object.
*
* @param string $name
*
* @return self
* @throws BannerDataException
*/
public static function newFromName( $name ) {
if ( !self::isValidBannerName( $name ) ) {
throw new BannerDataException( "Invalid banner name supplied." );
}
$obj = new self();
$obj->name = $name;
foreach ( $obj->dirtyFlags as $flag => &$value ) {
$value = true;
}
return $obj;
}
/**
* Get the unique ID for this banner.
*
* @return int
*/
public function getId() {
$this->populateBasicData();
return $this->id;
}
/**
* Get the unique name for this banner.
*
* This specifically does not include namespace or other prefixing.
*
* @return null|string
*/
public function getName() {
// Optimization: Speed up BannerMessageGroup's getKeys and getDefinitions.
//
// BannerMessageGroup calls self::fromName which populates $this->name.
// Translate calls BannerMessageGroup::getKeys(), which calls
// self::getMessageFieldsFromCache to load the message keys.
// self::getMessageFieldsFromCache calls self::getMessageFieldsCacheKey which calls
// this method. self::populateBasicData does a database query, which is not needed
// if we only need to know the banner name, which we already have.
if ( $this->name === null ) {
$this->populateBasicData();
}
return $this->name;
}
/**
* Should we allocate this banner to anonymous users.
*
* @return bool
*/
public function allocateToAnon() {
$this->populateBasicData();
return $this->allocateAnon;
}
/**
* Should we allocate this banner to logged in users.
*
* @return bool
*/
public function allocateToLoggedIn() {
$this->populateBasicData();
return $this->allocateLoggedIn;
}
/**
* Set user state allocation properties for this banner
*
* @param bool $anon Should the banner be allocated to logged out users.
* @param bool $loggedIn Should the banner be allocated to logged in users.
*
* @return $this
*/
public function setAllocation( $anon, $loggedIn ) {
$this->populateBasicData();
if ( ( $this->allocateAnon !== $anon ) || ( $this->allocateLoggedIn !== $loggedIn ) ) {
$this->setBasicDataDirty();
$this->allocateAnon = $anon;
$this->allocateLoggedIn = $loggedIn;
}
return $this;
}
/**
* Get the banner category.
*
* The category is the name of the cookie stored on the users computer. In this way
* banners in the same category may share settings.
*
* @return string
*/
public function getCategory() {
$this->populateBasicData();
return $this->category;
}
/**
* Set the banner category.
*
* @see Banner->getCategory()
*
* @param string $value
*
* @return $this
*/
public function setCategory( $value ) {
$this->populateBasicData();
if ( $this->category !== $value ) {
$this->setBasicDataDirty();
$this->category = $value;
}
return $this;
}
/**
* Obtain an array of all categories currently seen attached to banners
* @return string[]
*/
public static function getAllUsedCategories() {
$db = CNDatabase::getDb();
$res = $db->newSelectQueryBuilder()
->select( 'tmp_category' )
->distinct()
->from( 'cn_templates' )
->caller( __METHOD__ )
->orderBy( 'tmp_category' )
->fetchResultSet();
$categories = [];
foreach ( $res as $row ) {
$categories[$row->tmp_category] = $row->tmp_category;
}
return $categories;
}
/**
* Remove invalid characters from a category string that has been magic
* word expanded.
*
* @param string $cat Category string to sanitize
*
* @return string
*/
public static function sanitizeRenderedCategory( $cat ) {
return preg_replace( '/[^a-zA-Z0-9_]/', '', $cat );
}
/**
* Should the banner be considered archived and hidden from default view
*
* @return bool
*/
public function isArchived() {
$this->populateBasicData();
return $this->archived;
}
/**
* Is this banner meant to be used as a template for other banners
*
* @return bool
* @throws BannerDataException
* @throws BannerExistenceException
*/
public function isTemplate() {
$this->populateBasicData();
return $this->template;
}
/**
* Mark banner as a template
*
* @param bool $value
* @throws BannerDataException
* @throws BannerExistenceException
*/
public function setIsTemplate( $value ) {
$this->populateBasicData();
if ( $this->template !== $value ) {
$this->setBasicDataDirty();
$this->template = $value;
}
}
/**
* Populates basic banner data by querying the cn_templates table
*
* @throws BannerDataException If neither a name or ID can be used to query for data
* @throws BannerExistenceException If no banner data was received
*/
private function populateBasicData() {
if ( $this->dirtyFlags['basic'] !== null ) {
return;
}
$db = CNDatabase::getDb();
// What are we using to select on?
if ( $this->name !== null ) {
$selector = [ 'tmp_name' => $this->name ];
} elseif ( $this->id !== null ) {
$selector = [ 'tmp_id' => $this->id ];
} else {
throw new BannerDataException( 'Cannot retrieve banner data without name or ID.' );
}
// Query!
$rowRes = $db->newSelectQueryBuilder()
->select( [
'tmp_id',
'tmp_name',
'tmp_display_anon',
'tmp_display_account',
'tmp_archived',
'tmp_category',
'tmp_is_template'
] )
->from( 'cn_templates' )
->where( $selector )
->caller( __METHOD__ )
->fetchResultSet();
// Extract the dataz!
$row = $rowRes->fetchObject();
if ( $row ) {
$this->id = (int)$row->tmp_id;
$this->name = $row->tmp_name;
$this->allocateAnon = (bool)$row->tmp_display_anon;
$this->allocateLoggedIn = (bool)$row->tmp_display_account;
$this->archived = (bool)$row->tmp_archived;
$this->category = $row->tmp_category;
$this->template = (bool)$row->tmp_is_template;
} else {
$keystr = [];
foreach ( $selector as $key => $value ) {
$keystr[] = "{$key} = {$value}";
}
$keystr = implode( " AND ", $keystr );
throw new BannerExistenceException(
"No banner exists where {$keystr}. Could not load."
);
}
// Set the dirty flag to not dirty because we just loaded clean data
$this->setBasicDataDirty( false );
}
/**
* Sets the flag which will save basic metadata on next save()
* @param bool $dirty
*/
private function setBasicDataDirty( $dirty = true ) {
$this->dirtyFlags['basic'] = $dirty;
}
/**
* Helper function to initializeDbForNewBanner()
*
* @param IDatabase $db
*/
private function initializeDbBasicData( IDatabase $db ) {
$db->newInsertQueryBuilder()
->insertInto( 'cn_templates' )
->row( [ 'tmp_name' => $this->name ] )
->caller( __METHOD__ )
->execute();
$this->id = $db->insertId();
}
/**
* Helper function to saveBannerInternal() for saving basic banner metadata
* @param IDatabase $db
*/
private function saveBasicData( IDatabase $db ) {
if ( $this->dirtyFlags['basic'] ) {
$db->newUpdateQueryBuilder()
->update( 'cn_templates' )
->set( [
'tmp_display_anon' => (int)$this->allocateAnon,
'tmp_display_account' => (int)$this->allocateLoggedIn,
'tmp_archived' => (int)$this->archived,
'tmp_category' => $this->category,
'tmp_is_template' => (int)$this->template
] )
->where( [
'tmp_id' => $this->id
] )
->caller( __METHOD__ )
->execute();
}
}
/**
* Get the devices that this banner should be allocated to.
*
* Array is in the form of {Device internal ID => Device header name}
*
* @return string[]
*/
public function getDevices() {
$this->populateDeviceTargetData();
return $this->devices;
}
/**
* Set the devices that this banner should be allocated to.
*
* @param string[]|string $devices Header name of devices. E.g. {'android', 'desktop'}
*
* @return $this
* @throws BannerDataException on unknown device header name.
*/
public function setDevices( $devices ) {
$this->populateDeviceTargetData();
$knownDevices = CNDeviceTarget::getAvailableDevices( true );
$devices = (array)$devices;
$devices = array_unique( array_values( $devices ) );
sort( $devices );
if ( $devices != $this->devices ) {
$this->devices = [];
foreach ( $devices as $device ) {
if ( !$device ) {
// Empty...
continue;
} elseif ( !array_key_exists( $device, $knownDevices ) ) {
throw new BannerDataException( "Device name '$device' not known! Cannot add." );
} else {
$this->devices[$knownDevices[$device]['id']] = $device;
}
}
$this->markDeviceTargetDataDirty();
}
return $this;
}
/**
* Populates device targeting data by querying the cn_template_devices table.
*
* @see CNDeviceTarget for more information about mapping.
*/
private function populateDeviceTargetData() {
if ( $this->dirtyFlags['devices'] !== null ) {
return;
}
$db = CNDatabase::getDb();
$rowObj = $db->newSelectQueryBuilder()
->select( [ 'devices.dev_id', 'dev_name' ] )
->from( 'cn_template_devices', 'tdev' )
->join( 'cn_known_devices', 'devices', 'tdev.dev_id = devices.dev_id' )
->where( [
'tdev.tmp_id' => $this->getId(),
] )
->caller( __METHOD__ )
->fetchResultSet();
foreach ( $rowObj as $row ) {
$this->devices[ intval( $row->dev_id ) ] = $row->dev_name;
}
$this->markDeviceTargetDataDirty( false );
}
/**
* Sets the flag which will force saving of device targeting data on next save()
* @param bool $dirty
*/
private function markDeviceTargetDataDirty( $dirty = true ) {
$this->dirtyFlags['devices'] = $dirty;
}
/**
* Helper function to saveBannerInternal()
*
* @param IDatabase $db
*/
private function saveDeviceTargetData( IDatabase $db ) {
if ( $this->dirtyFlags['devices'] ) {
// Remove all entries from the table for this banner
$db->newDeleteQueryBuilder()
->deleteFrom( 'cn_template_devices' )
->where( [ 'tmp_id' => $this->getId() ] )
->caller( __METHOD__ )
->execute();
// Add the new device mappings
if ( $this->devices ) {
$modifyArray = [];
foreach ( $this->devices as $deviceId => $deviceName ) {
$modifyArray[] = [ 'tmp_id' => $this->getId(), 'dev_id' => $deviceId ];
}
$db->newInsertQueryBuilder()
->insertInto( 'cn_template_devices' )
->rows( $modifyArray )
->caller( __METHOD__ )
->execute();
}
}
}
/**
* @return array Keys are names of enabled mixins; valeus are mixin params.
* @see $wgCentralNoticeBannerMixins
* TODO: Remove. See T225831.
*/
public function getMixins() {
$this->populateMixinData();
return $this->mixins;
}
/**
* Set the banner mixins to enable.
*
* @param array $mixins Names of mixins to enable on this banner. Valid values
* come from @see $wgCentralNoticeBannerMixins
*
* @throws RangeException
* @return $this
*/
public function setMixins( $mixins ) {
global $wgCentralNoticeBannerMixins;
$this->populateMixinData();
$mixins = array_unique( $mixins );
sort( $mixins );
if ( $this->mixins != $mixins ) {
$this->markMixinDataDirty();
}
$this->mixins = [];
foreach ( $mixins as $mixin ) {
if ( !array_key_exists( $mixin, $wgCentralNoticeBannerMixins ) ) {
throw new RangeException( "Mixin does not exist: {$mixin}" );
}
$this->mixins[$mixin] = $wgCentralNoticeBannerMixins[$mixin];
}
return $this;
}
/**
* Populates mixin data from the cn_template_mixins table.
*/
private function populateMixinData() {
global $wgCentralNoticeBannerMixins;
if ( $this->dirtyFlags['mixins'] !== null ) {
return;
}
$dbr = CNDatabase::getDb();
$result = $dbr->newSelectQueryBuilder()
->select( 'mixin_name' )
->from( 'cn_template_mixins' )
->where( [
"tmp_id" => $this->getId(),
] )
->caller( __METHOD__ )
->fetchResultSet();
$this->mixins = [];
foreach ( $result as $row ) {
if ( !array_key_exists( $row->mixin_name, $wgCentralNoticeBannerMixins ) ) {
// We only want to warn here otherwise we'd never be able to
// edit the banner to fix the issue! The editor should warn
// when a deprecated mixin is being used; but also when we
// do deprecate something we should make sure nothing is using
// it!
wfLogWarning(
"Mixin does not exist: {$row->mixin_name}, included from banner {$this->name}"
);
}
$this->mixins[$row->mixin_name] = $wgCentralNoticeBannerMixins[$row->mixin_name];
}
$this->markMixinDataDirty( false );
}
/**
* Sets the flag which will force saving of mixin data upon next save()
* @param bool $dirty
*/
private function markMixinDataDirty( $dirty = true ) {
$this->dirtyFlags['mixins'] = $dirty;
}
/**
* @param IDatabase $db
*/
private function saveMixinData( IDatabase $db ) {
if ( $this->dirtyFlags['mixins'] ) {
$db->newDeleteQueryBuilder()
->deleteFrom( 'cn_template_mixins' )
->where( [ 'tmp_id' => $this->getId() ] )
->caller( __METHOD__ )
->execute();
foreach ( $this->mixins as $name => $params ) {
$name = trim( $name );
if ( !$name ) {
continue;
}
$db->newInsertQueryBuilder()
->insertInto( 'cn_template_mixins' )
->row( [
'tmp_id' => $this->getId(),
'page_id' => 0, // TODO: What were we going to use this for again?
'mixin_name' => $name,
] )
->caller( __METHOD__ )
->execute();
}
}
}
/**
* Returns language codes that are considered a priority for translations.
*
* If a language is in this list it means that the translation UI will promote
* translating them, and discourage translating other languages.
*
* @return string[]
*/
public function getPriorityLanguages() {
$this->populatePriorityLanguageData();
return $this->priorityLanguages;
}
/**
* Set language codes that should be considered a priority for translation.
*
* If a language is in this list it means that the translation UI will promote
* translating them, and discourage translating other languages.
*
* @param string[] $languageCodes
*
* @return $this
*/
public function setPriorityLanguages( $languageCodes ) {
$this->populatePriorityLanguageData();
$languageCodes = array_unique( (array)$languageCodes );
sort( $languageCodes );
if ( $this->priorityLanguages != $languageCodes ) {
$this->priorityLanguages = $languageCodes;
$this->markPriorityLanguageDataDirty();
}
return $this;
}
private function populatePriorityLanguageData() {
global $wgNoticeUseTranslateExtension;
if ( $this->dirtyFlags['prioritylang'] !== null ) {
return;
}
if ( $wgNoticeUseTranslateExtension ) {
$services = Services::getInstance();
$langs = $services->getMessageGroupMetadata()->get(
BannerMessageGroup::getTranslateGroupName( $this->getName() ),
'prioritylangs'
);
if ( !$langs ) {
// If priority langs is not set; MessageGroupMetadata::get will return false
$langs = '';
}
$this->priorityLanguages = explode( ',', $langs );
}
$this->markPriorityLanguageDataDirty( false );
}
private function markPriorityLanguageDataDirty( $dirty = true ) {
$this->dirtyFlags['prioritylang'] = $dirty;
}
private function savePriorityLanguageData() {
global $wgNoticeUseTranslateExtension;
if ( $wgNoticeUseTranslateExtension && $this->dirtyFlags['prioritylang'] ) {
$groupName = BannerMessageGroup::getTranslateGroupName( $this->getName() );
$services = Services::getInstance();
$messageGroupMetadata = $services->getMessageGroupMetadata();
if ( $this->priorityLanguages === [] ) {
// Using false to delete the value instead of writing empty content
$messageGroupMetadata->set( $groupName, 'prioritylangs', false );
} else {
$messageGroupMetadata->set(
$groupName,
'prioritylangs',
implode( ',', $this->priorityLanguages )
);
}
}
}
public function getDbKey() {
$name = $this->getName();
return "Centralnotice-template-{$name}";
}
public function getTitle() {
return Title::newFromText( $this->getDbKey(), NS_MEDIAWIKI );
}
/**
* Return the names of campaigns that this banner is currently used in.
*
* @return string[]
*/
public function getCampaignNames() {
$dbr = CNDatabase::getDb();
$result = $dbr->newSelectQueryBuilder()
->select( 'notices.not_name' )
->from( 'cn_notices', 'notices' )
->join( 'cn_assignments', 'assignments', 'notices.not_id = assignments.not_id' )
->where( [
'assignments.tmp_id' => $this->getId(),
] )
->caller( __METHOD__ )
->fetchResultSet();
$campaigns = [];
foreach ( $result as $row ) {
$campaigns[] = $row->not_name;
}
return $campaigns;
}
/**
* Returns an array of Title objects that have been included as templates
* in this banner.
*
* @return Title[]
*/
public function getIncludedTemplates() {
return $this->getTitle()->getTemplateLinksFrom();
}
/**
* Get the raw body HTML for the banner.
*
* @return string HTML
*/
public function getBodyContent() {
$this->populateBodyContent();
return $this->bodyContent;
}
/**
* Set the raw body HTML for the banner.
*
* @param string $text HTML
*
* @return $this
*/
public function setBodyContent( $text ) {
$this->populateBodyContent();
if ( $this->bodyContent !== $text ) {
$this->bodyContent = $text;
$this->markBodyContentDirty();
}
return $this;
}
private function populateBodyContent() {
if ( $this->dirtyFlags['content'] !== null ) {
return;
}
$curRev = MediaWikiServices::getInstance()
->getRevisionLookup()
->getRevisionByTitle( $this->getTitle() );
if ( !$curRev ) {
throw new BannerContentException( "No content for banner: {$this->name}" );
}
$content = $curRev->getContent( SlotRecord::MAIN );
$this->bodyContent = ( $content instanceof TextContent ) ? $content->getText() : null;
$this->markBodyContentDirty( false );
}
/**
* @param bool $dirty If true, we're storing a flag that means the
* in-memory banner content is newer than what's stored in the database.
* If false, we're clearing that bit.
*/
private function markBodyContentDirty( $dirty = true ) {
$this->dirtyFlags['content'] = $dirty;
}
/**
* @param string|null $summary
* @param User $user
*/
private function saveBodyContent( $summary, User $user ) {
global $wgNoticeUseTranslateExtension;
if ( $this->dirtyFlags['content'] ) {
$wikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $this->getTitle() );
if ( $summary === null ) {
$summary = '';
}
$contentObj = ContentHandler::makeContent( $this->bodyContent, $wikiPage->getTitle() );
$tags = [ 'centralnotice' ];
$pageResult = $wikiPage->doUserEditContent(
$contentObj,
$user,
$summary,
EDIT_FORCE_BOT,
false, // $originalRevId
$tags
);
self::protectBannerContent( $wikiPage, $user );
if ( $wgNoticeUseTranslateExtension ) {
// Get the revision and page ID of the page that was created/modified
// @phan-suppress-next-line PhanTypeArraySuspiciousNullable
if ( $pageResult->value['revision-record'] ) {
// @phan-suppress-next-line PhanTypeArraySuspiciousNullable
$revisionRecord = $pageResult->value['revision-record'];
$revisionId = $revisionRecord->getId();
$pageId = $revisionRecord->getPageId();
// If the banner includes translatable messages, tag it for translation
$fields = $this->extractMessageFields();
if ( count( $fields ) > 0 ) {
// Tag the banner for translation
self::addTag( self::TRANSLATE_BANNER_TAG, $revisionId, $pageId, (string)$this->getId() );
$this->runTranslateJob = true;
}
$this->invalidateCache( $fields );
}
}
}
}
public function getMessageField( $field_name ) {
return new BannerMessage( $this->getName(), $field_name );
}
/**
* Returns all the message fields in a banner
*
* Check the cache first, then calculate if necessary. Will always prefer the cache.
* @see Banner::extractMessageFields()
*
* @return array
*/
public function getMessageFieldsFromCache() {
$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
$key = $this->getMessageFieldsCacheKey( $cache );
return $cache->getWithSetCallback(
$key,
$cache::TTL_MONTH,
function () {
return $this->extractMessageFields();
},
[ 'checkKeys' => [ $key ], 'lockTSE' => 60 ]
);
}
/**
* @param array|null $newFields Optional new result of the extractMessageFields()
*/
public function invalidateCache( $newFields = null ) {
// Update cache after the DB transaction finishes
CNDatabase::getDb()->onTransactionCommitOrIdle(
function () use ( $newFields ) {
$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
$key = $this->getMessageFieldsCacheKey( $cache );
$cache->touchCheckKey( $key );
if ( $newFields !== null ) {
// May as well set the new volatile cache value in the local datacenter
$cache->set( $key, $newFields, $cache::TTL_MONTH );
}
},
__METHOD__
);
}
/**
* @param WANObjectCache $cache
* @return mixed
*/
private function getMessageFieldsCacheKey( $cache ) {
return $cache->makeKey( 'centralnotice', 'bannerfields', $this->getName() );
}
/**
* Extract the raw fields and field names from the banner body source.
*
* Always recalculate. If you want the cached value, please use getMessageFieldsFromCache.
*
* @return array
*/
public function extractMessageFields() {
$parser = MediaWikiServices::getInstance()->getParser();
$expanded = $parser->parse(
$this->getBodyContent(), $this->getTitle(),
ParserOptions::newFromContext( RequestContext::getMain() )
)->getText();
// Also search the preload js for fields.
$renderer = new BannerRenderer( RequestContext::getMain(), $this );
$expanded .= $renderer->getPreloadJsRaw();
// Extract message fields from the banner body
$fields = [];
$allowedChars = Title::legalChars();
// Janky custom syntax to pass arguments to a message, broken and unused:
// "{{{fieldname:arg1|arg2}}}". This is why ':' is forbidden. FIXME: Remove.
// Also forbid ',' so we can use it as a separator between banner name and
// message name in banner content, and thus insert messages from other banners.
// ' ' is forbidden to avoid problems in the UI.
$allowedChars = str_replace( [ ':', ',', ' ' ], '', $allowedChars );
preg_match_all( "/{{{([$allowedChars]+)(:[^}]*)?}}}/u", $expanded, $fields );
// Remove duplicate keys and count occurrences
$unique_fields = array_unique( array_flip( $fields[1] ) );
$fields = array_intersect_key( array_count_values( $fields[1] ), $unique_fields );
$fields = array_diff_key( $fields, array_flip( $renderer->getMagicWords() ) );
return $fields;
}
/**
* Returns a list of messages that are either published or in the CNBanner translation
*
* @param bool $inTranslation If true and using group translation this will return
* all the messages that are in the translation system
*
* @return array A list of languages with existing field translations
*/
public function getAvailableLanguages( $inTranslation = false ) {
global $wgLanguageCode;
$availableLangs = [];
// Bit of an ugly hack to get just the banner prefix
$prefix = $this->getMessageField( '' )
->getDbKey( null, $inTranslation ? NS_CN_BANNER : NS_MEDIAWIKI );
$db = CNDatabase::getDb();
$result = $db->newSelectQueryBuilder()
->select( 'page_title' )
->from( 'page' )
->where( [
'page_namespace' => $inTranslation ? NS_CN_BANNER : NS_MEDIAWIKI,
$db->expr( 'page_title', IExpression::LIKE, new LikeValue( $prefix, $db->anyString() ) ),
] )
->caller( __METHOD__ )
->fetchResultSet();
foreach ( $result as $row ) {
if (
preg_match(
"/\Q{$prefix}\E([^\/]+)(?:\/([a-z_]+))?/", $row->page_title,
$matches
)
) {
$lang = $matches[2] ?? $wgLanguageCode;
$availableLangs[$lang] = true;
}
}
return array_keys( $availableLangs );
}
/**
* Saves any changes made to the banner object into the database
*
* @param User $user
* @param string|null $summary Summary (comment) to associate with all changes,
* including banner content and messages (which are implemented as wiki
* pages).
*
* @return $this
* @throws Exception
*/
public function save( User $user, $summary = null ) {
$db = CNDatabase::getDb();
$action = 'modified';
try {
// Don't move this to saveBannerInternal--can't be in a transaction
// TODO: explain why not. Is text in another database?
$this->saveBodyContent( $summary, $user );
// Open a transaction so that everything is consistent
$db->startAtomic( __METHOD__ );
if ( !$this->exists() ) {
$action = 'created';
$this->initializeDbForNewBanner( $db );
}
$this->saveBannerInternal( $db );
$this->logBannerChange( $action, $user, $summary );
$db->endAtomic( __METHOD__ );
// Clear the dirty flags
foreach ( $this->dirtyFlags as $flag => &$value ) {
$value = false;
}
if ( $this->runTranslateJob ) {
// Must be run after banner has finished saving due to some dependencies that
// exist in the render job.
// TODO: This will go away if we start tracking messages in database :)
MessageGroups::singleton()->recache();
MediaWikiServices::getInstance()->getJobQueueGroup()->push(
RebuildMessageIndexJob::newJob()
);
$this->runTranslateJob = false;
}
} catch ( Exception $ex ) {
$db->rollback( __METHOD__ );
throw $ex;
}
return $this;
}
/**
* Called before saveBannerInternal() when a new to the database banner is
* being saved. Intended to create all table rows required such that any
* additional operation can be an UPDATE statement.
*
* @param IDatabase $db
*/
private function initializeDbForNewBanner( IDatabase $db ) {
$this->initializeDbBasicData( $db );
}
/**
* Helper function to save(). This is wrapped in a database transaction and
* is intended to be easy to override -- though overriding function should
* call this at some point. :)
*
* Because it is wrapped in a database transaction; most MediaWiki calls
* like page saving cannot be performed here.
*
* Dirty flags are not globally reset until after this function is called.
*
* @param IDatabase $db
*
* @throws BannerExistenceException
*/
private function saveBannerInternal( IDatabase $db ) {
$this->saveBasicData( $db );
$this->saveDeviceTargetData( $db );
$this->saveMixinData( $db );
$this->savePriorityLanguageData();
}
/**
* Archive a banner.
*
* TODO: Remove data from translation, in place replace all templates
*
* @return $this
*/
public function archive() {
if ( $this->dirtyFlags['basic'] === null ) {
$this->populateBasicData();
}
$this->dirtyFlags['basic'] = true;
$this->archived = true;
return $this;
}
public function cloneBanner( $destination, $user, $summary = null ) {
if ( !self::isValidBannerName( $destination ) ) {
throw new BannerDataException( "Banner name must be in format /^[A-Za-z0-9_]+$/" );
}
$destBanner = self::newFromName( $destination );
if ( $destBanner->exists() ) {
throw new BannerExistenceException( "Banner by that name already exists!" );
}
$destBanner->setAllocation( $this->allocateToAnon(), $this->allocateToLoggedIn() );
$destBanner->setCategory( $this->getCategory() );
$destBanner->setDevices( $this->getDevices() );
$destBanner->setMixins( array_keys( $this->getMixins() ) );
$destBanner->setPriorityLanguages( $this->getPriorityLanguages() );
$destBanner->setBodyContent( $this->getBodyContent() );
// Populate the message fields
$langs = $this->getAvailableLanguages();
$fields = $this->extractMessageFields();
foreach ( $langs as $lang ) {
foreach ( $fields as $field => $count ) {
$text = $this->getMessageField( $field )->getContents( $lang );
if ( $text !== null ) {
$destBanner->getMessageField( $field )
->update( $text, $lang, $user, $summary );
}
}
}
// Save it!
$destBanner->save( $user, $summary );
$this->invalidateCache( $fields );
return $destBanner;
}
public function remove( User $user ) {
self::removeBanner( $this->getName(), $user );
}
public static function removeBanner( $name, $user, $summary = null ) {
global $wgNoticeUseTranslateExtension;
$bannerObj = self::fromName( $name );
$id = $bannerObj->getId();
$dbr = CNDatabase::getDb();
$res = $dbr->newSelectQueryBuilder()
->select( 'asn_id' )
->from( 'cn_assignments' )
->where( [ 'tmp_id' => $id ] )
->caller( __METHOD__ )
->fetchResultSet();
if ( $res->numRows() > 0 ) {
throw new LogicException( 'Cannot remove a template still bound to a campaign!' );
} else {
// Log the removal of the banner
// FIXME: this log line will display changes with inverted sense
$bannerObj->logBannerChange( 'removed', $user, $summary );
// Delete banner record from the CentralNotice cn_templates table
$dbw = CNDatabase::getDb();
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'cn_templates' )
->where( [ 'tmp_id' => $id ] )
->caller( __METHOD__ )
->execute();
// Delete the MediaWiki page that contains the banner source
// TODO Inconsistency: deletion of banner content is not recorded
// as a bot edit, so it does not appear on the CN logs page. Also,
// related messages are not deleted.
$wikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $bannerObj->getTitle() );
$wikiPage->doDeleteArticleReal( $summary ?: '', $user );
if ( $wgNoticeUseTranslateExtension ) {
// Remove any revision tags related to the banner
self::removeTag( self::TRANSLATE_BANNER_TAG, $wikiPage->getId() );
$services = Services::getInstance();
// Add the preferred language metadata if it exists
$services->getMessageGroupMetadata()->set(
BannerMessageGroup::getTranslateGroupName( $name ),
'prioritylangs',
false
);
}
}
}
/**
* Add a revision tag for the banner
* @param string $tag The name of the tag
* @param int $revisionId ID of the revision
* @param int $pageId ID of the MediaWiki page for the banner
* @param string $bannerId ID of banner this revtag belongs to
* @throws Exception
*/
public static function addTag( $tag, $revisionId, $pageId, $bannerId ) {
$dbw = CNDatabase::getDb();
if ( is_object( $revisionId ) ) {
throw new LogicException( 'Got object, excepted id' );
}
// There should only ever be one tag applied to a banner object
self::removeTag( $tag, $pageId );
$conds = [
'rt_page' => $pageId,
'rt_type' => $tag,
'rt_revision' => $revisionId
];
if ( $bannerId !== null ) {
$conds['rt_value'] = $bannerId;
}
$dbw->newInsertQueryBuilder()
->insertInto( 'revtag' )
->row( $conds )
->caller( __METHOD__ )
->execute();
}
/**
* Make sure banner is not tagged with specified tag
* @param string $tag The name of the tag
* @param int $pageId ID of the MediaWiki page for the banner
* @throws Exception
*/
private static function removeTag( $tag, $pageId ) {
$dbw = CNDatabase::getDb();
$conds = [
'rt_page' => $pageId,
'rt_type' => $tag
];
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'revtag' )
->where( $conds )
->caller( __METHOD__ )
->execute();
}
/**
* Given one or more campaign ids, return all banners bound to them
*
* @param int|array $campaigns list of campaign numeric IDs
*
* @return array a 2D array of banners with associated weights and settings
*/
public static function getCampaignBanners( $campaigns ) {
$dbr = CNDatabase::getDb();
$banners = [];
if ( $campaigns ) {
$res = $dbr->newSelectQueryBuilder()
// Aliases (keys) are needed to avoid problems with table prefixes
->select( [
'tmp_name',
'tmp_weight',
'tmp_display_anon',
'tmp_display_account',
'tmp_category',
'not_name',
'not_preferred',
'asn_bucket',
'not_buckets',
'not_throttle',
'dev_name',
] )
->from( 'cn_notices', 'notices' )
->join( 'cn_assignments', 'assignments', 'notices.not_id = assignments.not_id' )
->join( 'cn_templates', 'templates', 'assignments.tmp_id = templates.tmp_id' )
->leftJoin( 'cn_template_devices', 'template_devices', 'template_devices.tmp_id = assignments.tmp_id' )
->join( 'cn_known_devices', 'known_devices', 'known_devices.dev_id = template_devices.dev_id' )
->where( [
'notices.not_id' => $campaigns,
] )
->caller( __METHOD__ )
->fetchResultSet();
foreach ( $res as $row ) {
$banners[] = [
// name of the banner
'name' => $row->tmp_name,
// weight assigned to the banner
'weight' => intval( $row->tmp_weight ),
// display to anonymous users?
'display_anon' => intval( $row->tmp_display_anon ),
// display to logged in users?
'display_account' => intval( $row->tmp_display_account ),
// fundraising banner?
'fundraising' => intval( $row->tmp_category === 'fundraising' ),
// device this banner can target
'device' => $row->dev_name,
// campaign the banner is assigned to
'campaign' => $row->not_name,
// z level of the campaign
'campaign_z_index' => $row->not_preferred,
'campaign_num_buckets' => intval( $row->not_buckets ),
'campaign_throttle' => intval( $row->not_throttle ),
'bucket' => ( intval( $row->not_buckets ) == 1 )
? 0 : intval( $row->asn_bucket ),
];
}
}
return $banners;
}
/**
* Return settings for a banner
*
* @param string $bannerName name of banner
* @param bool $detailed if true, get some more expensive info
*
* @return array an array of banner settings
* @throws RangeException
*/
public static function getBannerSettings( $bannerName, $detailed = true ) {
$banner = self::fromName( $bannerName );
if ( !$banner->exists() ) {
throw new RangeException( "Banner doesn't exist!" );
}
$details = [
'anon' => (int)$banner->allocateToAnon(),
'account' => (int)$banner->allocateToLoggedIn(),
// TODO: Death to this!
'fundraising' => (int)( $banner->getCategory() === 'fundraising' ),
'category' => $banner->getCategory(),
'controller_mixin' => implode( ",", array_keys( $banner->getMixins() ) ),
'devices' => array_values( $banner->getDevices() ),
];
if ( $detailed ) {
$details['prioritylangs'] = $banner->getPriorityLanguages();
}
return $details;
}
/**
* FIXME: a little thin, it's just enough to get the job done
*
* @param string $name
* @param int $ts
* @return array|null banner settings as an associative array, with these properties:
* display_anon: 0/1 whether the banner is displayed to anonymous users
* display_account: 0/1 same, for logged-in users
* fundraising: 0/1, is in the fundraising group
* device: device key
*/
public static function getHistoricalBanner( $name, $ts ) {
$id = self::fromName( $name )->getId();
$dbr = CNDatabase::getDb();
$newestLog = $dbr->newSelectQueryBuilder()
->select( [
"log_id" => "MAX(tmplog_id)",
] )
->from( 'cn_template_log' )
->where( [
$dbr->expr( 'tmplog_timestamp', '<=', $dbr->timestamp( $ts ) ),
'tmplog_template_id' => $id,
] )
->caller( __METHOD__ )
->fetchRow();
if ( $newestLog->log_id === null ) {
return null;
}
$row = $dbr->newSelectQueryBuilder()
->select( [
"display_anon" => "tmplog_end_anon",
"display_account" => "tmplog_end_account",
"fundraising" => "tmplog_end_fundraising",
] )
->from( 'cn_template_log' )
->where( [
"tmplog_id" => $newestLog->log_id,
] )
->caller( __METHOD__ )
->fetchRow();
return [
'display_anon' => (int)$row->display_anon,
'display_account' => (int)$row->display_account,
'fundraising' => (int)$row->fundraising,
'devices' => [ 'desktop' ],
];
}
/**
* @param string $name
* @param User $user
* @param Banner $template
* @param string|null $summary
* @return string|null error message key or null on success
* @throws BannerDataException
* @throws BannerExistenceException
*/
public static function addFromBannerTemplate( $name, $user, Banner $template, $summary = null ) {
if ( !$template->isTemplate() ) {
return 'centralnotice-banner-template-error';
}
return static::addBanner(
$name, $template->getBodyContent(), $user,
$template->allocateToAnon(),
$template->allocateToLoggedIn(),
$template->getMixins(),
$template->getPriorityLanguages(),
$template->getDevices(),
$summary,
false,
$template->getCategory()
);
}
/**
* Create a new banner
*
* @param string $name name of banner
* @param string $body content of banner
* @param User $user User causing the change
* @param bool $displayAnon flag for display to anonymous users
* @param bool $displayAccount flag for display to logged in users
* @param array $mixins list of mixins (optional)
* @param array $priorityLangs Array of priority languages for the translate extension
* @param array|null $devices Array of device names this banner is targeted at
* @param string|null $summary Optional summary of changes for logging
* @param bool $isTemplate Is banner marked as a template
* @param string|null $category Category of the banner
*
* @return string|null error message key or null on success
* @throws BannerDataException
*/
public static function addBanner( $name, $body, $user, $displayAnon,
$displayAccount, $mixins = [], $priorityLangs = [], $devices = null,
$summary = null, $isTemplate = false, $category = null
) {
// Default initial value for devices
if ( $devices === null ) {
$devices = [ 'desktop' ];
}
// Set default value for category
if ( !$category ) {
$category = '{{{campaign}}}';
}
if ( $name == '' || !self::isValidBannerName( $name ) || $body == '' ) {
return 'centralnotice-null-string';
}
$banner = self::newFromName( $name );
if ( $banner->exists() ) {
return 'centralnotice-template-exists';
}
$banner->setAllocation( $displayAnon, $displayAccount );
$banner->setCategory( $category );
$banner->setDevices( $devices );
$banner->setPriorityLanguages( $priorityLangs );
$banner->setBodyContent( $body );
$banner->setIsTemplate( $isTemplate );
$banner->setMixins( $mixins );
$banner->save( $user, $summary );
return null;
}
/**
* Log setting changes related to a banner
*
* @param string $action 'created', 'modified', or 'removed'
* @param User $user The user causing the change
* @param string|null $summary Summary (comment) for this action
*/
public function logBannerChange( $action, $user, $summary = null ) {
ChoiceDataProvider::invalidateCache();
// Summary shouldn't actually come in null, but just in case...
if ( $summary === null ) {
$summary = '';
}
$endSettings = [];
if ( $action !== 'removed' ) {
$endSettings = self::getBannerSettings( $this->getName(), true );
}
$dbw = CNDatabase::getDb();
$log = [
'tmplog_timestamp' => $dbw->timestamp(),
'tmplog_user_id' => $user->getId(),
'tmplog_action' => $action,
'tmplog_template_id' => $this->getId(),
'tmplog_template_name' => $this->getName(),
'tmplog_content_change' => (int)$this->dirtyFlags['content'],
'tmplog_comment' => $summary,
];
foreach ( $endSettings as $key => $value ) {
if ( is_array( $value ) ) {
$value = FormatJson::encode( $value );
}
$log[ 'tmplog_end_' . $key ] = $value;
}
$dbw->newInsertQueryBuilder()
->insertInto( 'cn_template_log' )
->row( $log )
->caller( __METHOD__ )
->execute();
}
/**
* Validation function for banner names. Will return true iff the name fits
* the generic format of letters, numbers, and dashes.
*
* @param string $name The name to check
*
* @return bool True if valid
*/
public static function isValidBannerName( $name ) {
// Note: regex should coordinate with banner name validation
// in ext.centralNotice.adminUi.bannerSequence.js
return preg_match( '/^[A-Za-z0-9_]+$/', $name );
}
/**
* Check to see if a banner actually exists in the database
*
* @return bool
* @throws BannerDataException If it's a silly query
*/
public function exists() {
$db = CNDatabase::getDb();
if ( $this->name !== null ) {
$selector = [ 'tmp_name' => $this->name ];
} elseif ( $this->id !== null ) {
$selector = [ 'tmp_id' => $this->id ];
} else {
throw new BannerDataException(
'Cannot determine banner existence without name or ID.'
);
}
return (bool)$db->newSelectQueryBuilder()
->select( 'tmp_name' )
->from( 'cn_templates' )
->where( $selector )
->caller( __METHOD__ )
->fetchRow();
}
/**
* Get BannerMessage defined in a specific banner.
*
* @param string $bannerName
* @param string $messageName
* @return BannerMessage
*/
public static function getMessageFieldForBanner( $bannerName, $messageName ) {
return new BannerMessage( $bannerName, $messageName );
}
/**
* Apply cascading protection to the banner template or message
*
* @param WikiPage $wikiPage
* @param User $user
* @param bool $isTranslatedMessage if true, protect with $wgCentralNoticeMessageProtectRight
* @throws BannerContentException
*/
public static function protectBannerContent(
WikiPage $wikiPage, User $user, $isTranslatedMessage = false
) {
if ( $isTranslatedMessage ) {
global $wgCentralNoticeMessageProtectRight;
if ( $wgCentralNoticeMessageProtectRight === '' ) {
return;
}
$protectionRight = $wgCentralNoticeMessageProtectRight;
} else {
$protectionRight = 'centralnotice-admin';
}
$limits = [
'edit' => $protectionRight,
'move' => $protectionRight,
];
$expiry = [
'edit' => 'infinity',
'move' => 'infinity',
];
$cascade = true;
$reason = wfMessage( 'centralnotice-banner-protection-log-reason' )
->inContentLanguage()->text();
$status = $wikiPage->doUpdateRestrictions(
$limits, $expiry, $cascade, $reason, $user
);
if ( !$status->isGood() || !$cascade ) {
throw new BannerContentException(
'Unable to protect banner' . $status->getMessage()->text()
);
}
}
}

File Metadata

Mime Type
text/x-php
Expires
Aug 15 2026, 16:43 (12 w, 3 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
fe/22/5886acf287309c51584618ebfc9f
Default Alt Text
Banner.php (46 KB)

Event Timeline