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