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 | |
5 | /** |
6 | * Renders banner contents as jsonp. |
7 | */ |
8 | class 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 | } |