Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 84 |
|
0.00% |
0 / 5 |
CRAP | |
0.00% |
0 / 1 |
| SpecialBannerLoader | |
0.00% |
0 / 84 |
|
0.00% |
0 / 5 |
552 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| execute | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
42 | |||
| getParamsAndSetState | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
72 | |||
| sendHeaders | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
| getJsNotice | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
30 | |||
| 1 | <?php |
| 2 | |
| 3 | use MediaWiki\Html\Html; |
| 4 | use MediaWiki\Json\FormatJson; |
| 5 | use MediaWiki\SpecialPage\UnlistedSpecialPage; |
| 6 | |
| 7 | /** |
| 8 | * Renders banner contents as jsonp. |
| 9 | */ |
| 10 | class SpecialBannerLoader extends UnlistedSpecialPage { |
| 11 | /** |
| 12 | * Seconds leeway for checking stale choice data. Should be the same |
| 13 | * as CAMPAIGN_STALENESS_LEEWAY in ext.centralNotice.display.chooser.js. |
| 14 | */ |
| 15 | private const CAMPAIGN_STALENESS_LEEWAY = 900; |
| 16 | |
| 17 | /* Possible values for $this->cacheResponse */ |
| 18 | private const MAX_CACHE_NORMAL = 0; |
| 19 | private const MAX_CACHE_REDUCED = 1; |
| 20 | |
| 21 | /* Possible values for $this->requestType */ |
| 22 | private const USER_DISPLAY_REQUEST = 0; |
| 23 | private const TESTING_SAVED_REQUEST = 1; |
| 24 | private const PREVIEW_UNSAVED_REQUEST = 2; |
| 25 | |
| 26 | /** @var string Name of the chosen banner */ |
| 27 | private $bannerName; |
| 28 | |
| 29 | /** @var string|null Name of the campaign that the banner belongs to. */ |
| 30 | private $campaignName; |
| 31 | |
| 32 | /** @var string|null Content of the banner to be previewed */ |
| 33 | private $previewContent; |
| 34 | |
| 35 | /** @var string[]|null Unsaved messages for substitution in preview banner content */ |
| 36 | private $previewMessages; |
| 37 | |
| 38 | /** @var string[]|null */ |
| 39 | private $editToken; |
| 40 | |
| 41 | /** @var bool */ |
| 42 | private $debug; |
| 43 | |
| 44 | /** @var int Type of caching to set (see constants, above) */ |
| 45 | private $cacheResponse; |
| 46 | |
| 47 | /** @var int Request type (see constants, above) */ |
| 48 | private $requestType; |
| 49 | |
| 50 | public function __construct() { |
| 51 | // Register special page |
| 52 | parent::__construct( "BannerLoader" ); |
| 53 | } |
| 54 | |
| 55 | /** @inheritDoc */ |
| 56 | public function execute( $par ) { |
| 57 | $this->getOutput()->disable(); |
| 58 | |
| 59 | try { |
| 60 | $this->getParamsAndSetState(); |
| 61 | $out = $this->getJsNotice(); |
| 62 | |
| 63 | } catch ( EmptyBannerException ) { |
| 64 | $out = "mw.centralNotice.handleBannerLoaderError( 'Empty banner' );"; |
| 65 | |
| 66 | // Force reduced cache time |
| 67 | $this->cacheResponse = self::MAX_CACHE_REDUCED; |
| 68 | |
| 69 | } catch ( Exception $e ) { |
| 70 | if ( $e instanceof BannerPreviewPermissionsException ) { |
| 71 | $msg = $this->msg( 'centralnotice-preview-loader-permissions-error' )->escaped(); |
| 72 | } else { |
| 73 | $msg = $e->getMessage(); |
| 74 | } |
| 75 | |
| 76 | // @phan-suppress-next-line SecurityCheck-DoubleEscaped |
| 77 | $msgParamStr = $msg ? Html::encodeJsVar( $msg ) : ''; |
| 78 | |
| 79 | // For preview requests, a different error callback is needed. |
| 80 | if ( $this->requestType === self::PREVIEW_UNSAVED_REQUEST ) { |
| 81 | $callback = 'mw.centralNotice.adminUi.bannerEditor.handleBannerLoaderError'; |
| 82 | } else { |
| 83 | $callback = 'mw.centralNotice.handleBannerLoaderError'; |
| 84 | } |
| 85 | |
| 86 | $out = "$callback({$msgParamStr});"; |
| 87 | |
| 88 | // Force reduced cache time |
| 89 | $this->cacheResponse = self::MAX_CACHE_REDUCED; |
| 90 | |
| 91 | wfDebugLog( 'CentralNotice', $msg ); |
| 92 | } |
| 93 | |
| 94 | // We have to call this since we've disabled output. |
| 95 | // TODO See if there's a better way to do this, maybe OutputPage::setCdnMaxage()? |
| 96 | $this->sendHeaders(); |
| 97 | echo $out; |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * @throws MissingRequiredParamsException |
| 102 | * @throws BannerPreviewPermissionsException |
| 103 | */ |
| 104 | public function getParamsAndSetState() { |
| 105 | $request = $this->getRequest(); |
| 106 | |
| 107 | $this->campaignName = $request->getText( 'campaign' ); |
| 108 | $this->bannerName = $request->getText( 'banner' ); |
| 109 | $this->debug = $request->getFuzzyBool( 'debug' ); |
| 110 | $this->previewContent = $request->getText( 'previewcontent' ); |
| 111 | $this->previewMessages = $request->getArray( 'previewmessages' ); |
| 112 | $this->editToken = $request->getVal( 'token' ); |
| 113 | |
| 114 | // All request types should have at least a non-empty banner name |
| 115 | if ( !$this->bannerName ) { |
| 116 | throw new MissingRequiredParamsException(); |
| 117 | } |
| 118 | |
| 119 | // Only render preview content and messages for users with CN admin rights, on |
| 120 | // requests that were POSTed, with the correct edit token. This is to prevent |
| 121 | // malicious use of the reflection of unsanitized parameters. |
| 122 | if ( $this->getRequest()->wasPosted() && $this->previewContent ) { |
| 123 | |
| 124 | $this->requestType = self::PREVIEW_UNSAVED_REQUEST; |
| 125 | |
| 126 | // Check credentials |
| 127 | if ( |
| 128 | !$this->getUser()->isAllowed( 'centralnotice-admin' ) || |
| 129 | !$this->editToken || |
| 130 | !$this->getUser()->matchEditToken( $this->editToken ) |
| 131 | ) { |
| 132 | throw new BannerPreviewPermissionsException( $this->bannerName ); |
| 133 | } |
| 134 | |
| 135 | // Note: We don't set $this->cacheResponse since there's no caching for |
| 136 | // logged-in users anyway. |
| 137 | |
| 138 | // Distinguish a testing request for a saved banner by the absence of a campaign |
| 139 | } elseif ( !$this->campaignName ) { |
| 140 | $this->requestType = self::TESTING_SAVED_REQUEST; |
| 141 | $this->cacheResponse = self::MAX_CACHE_REDUCED; |
| 142 | |
| 143 | // We have at least a campaign name and a banner name, which means this is a |
| 144 | // normal request for a banner to display to a user. |
| 145 | } else { |
| 146 | $this->requestType = self::USER_DISPLAY_REQUEST; |
| 147 | $this->cacheResponse = self::MAX_CACHE_NORMAL; |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | /** |
| 152 | * Generate the HTTP response headers for the banner file, setting maxage cache time |
| 153 | * for front-send cache as appropriate. |
| 154 | * |
| 155 | * For anonymous users, set cache as per $this->cacheResponse, $wgNoticeBannerMaxAge |
| 156 | * and $wgNoticeBannerReducedMaxAge. Never cache for logged-in users. |
| 157 | * |
| 158 | * TODO Couldn't we cache for logged-in users? See T149873 |
| 159 | */ |
| 160 | private function sendHeaders() { |
| 161 | header( "Content-type: text/javascript; charset=utf-8" ); |
| 162 | |
| 163 | if ( !$this->getUser()->isRegistered() ) { |
| 164 | // Header tells front-end caches to retain the content for $sMaxAge seconds. |
| 165 | $sMaxAge = ( $this->cacheResponse === self::MAX_CACHE_NORMAL ) |
| 166 | ? $this->getConfig()->get( 'NoticeBannerMaxAge' ) |
| 167 | : $this->getConfig()->get( 'NoticeBannerReducedMaxAge' ); |
| 168 | |
| 169 | } else { |
| 170 | $sMaxAge = 0; |
| 171 | } |
| 172 | |
| 173 | header( "Cache-Control: public, s-maxage={$sMaxAge}, max-age=0" ); |
| 174 | } |
| 175 | |
| 176 | /** |
| 177 | * Generate the JS for the requested banner |
| 178 | * @return string of JavaScript containing a call to insertBanner() |
| 179 | * with JSON containing the banner content as the parameter |
| 180 | * @throws EmptyBannerException |
| 181 | * @throws StaleCampaignException |
| 182 | */ |
| 183 | public function getJsNotice() { |
| 184 | $banner = Banner::fromName( $this->bannerName ); |
| 185 | |
| 186 | if ( $this->requestType === self::USER_DISPLAY_REQUEST ) { |
| 187 | |
| 188 | // The following will throw a CampaignExistenceException if there's |
| 189 | // no such campaign. |
| 190 | $campaign = new Campaign( $this->campaignName ); |
| 191 | |
| 192 | // Check that this is from a campaign that hasn't ended. We might get old |
| 193 | // campaigns due to forever-cached JS somewhere. Note that we include some |
| 194 | // leeway and don't consider archived or enabled status because the |
| 195 | // campaign might just have been updated and there is a normal caching lag for |
| 196 | // the data about campaigns sent to browsers. |
| 197 | $endTimePlusLeeway = wfTimestamp( |
| 198 | TS_UNIX, |
| 199 | (int)$campaign->getEndTime()->getTimestamp() + self::CAMPAIGN_STALENESS_LEEWAY |
| 200 | ); |
| 201 | |
| 202 | $now = wfTimestamp(); |
| 203 | if ( $endTimePlusLeeway < $now ) { |
| 204 | throw new StaleCampaignException( |
| 205 | $this->bannerName, "Campaign: {$this->campaignName}" ); |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | if ( $this->requestType === self::PREVIEW_UNSAVED_REQUEST ) { |
| 210 | |
| 211 | $bannerRenderer = new BannerRenderer( |
| 212 | $this->getContext(), |
| 213 | $banner, |
| 214 | $this->campaignName, |
| 215 | $this->previewContent, |
| 216 | $this->previewMessages, |
| 217 | $this->debug |
| 218 | ); |
| 219 | |
| 220 | $jsCallbackFn = 'mw.centralNotice.adminUi.bannerEditor.updateBannerPreview'; |
| 221 | |
| 222 | } else { |
| 223 | if ( !$banner->exists() ) { |
| 224 | throw new EmptyBannerException( $this->bannerName ); |
| 225 | } |
| 226 | |
| 227 | $bannerRenderer = new BannerRenderer( |
| 228 | $this->getContext(), |
| 229 | $banner, |
| 230 | $this->campaignName, |
| 231 | null, |
| 232 | null, |
| 233 | $this->debug |
| 234 | ); |
| 235 | |
| 236 | $jsCallbackFn = 'mw.centralNotice.insertBanner'; |
| 237 | } |
| 238 | |
| 239 | $bannerHtml = $bannerRenderer->toHtml(); |
| 240 | $bannerArray = [ 'bannerHtml' => $bannerHtml ]; |
| 241 | $bannerJson = FormatJson::encode( $bannerArray ); |
| 242 | $preload = $bannerRenderer->getPreloadJs(); |
| 243 | |
| 244 | return "{$preload}\n{$jsCallbackFn}( {$bannerJson} );"; |
| 245 | } |
| 246 | } |