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