Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialBannerLoader
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 5
552
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 getParamsAndSetState
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
72
 sendHeaders
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getJsNotice
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3use MediaWiki\Html\Html;
4use MediaWiki\Json\FormatJson;
5use MediaWiki\SpecialPage\UnlistedSpecialPage;
6
7/**
8 * Renders banner contents as jsonp.
9 */
10class 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}