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 | 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 | } |