Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 148 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
RawAction | |
0.00% |
0 / 148 |
|
0.00% |
0 / 8 |
3192 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
requiresWrite | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
requiresUnblock | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onView | |
0.00% |
0 / 65 |
|
0.00% |
0 / 1 |
552 | |||
getRawText | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
182 | |||
getOldId | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
110 | |||
getContentType | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
42 |
1 | <?php |
2 | /** |
3 | * Raw page text accessor |
4 | * |
5 | * Copyright © 2004 Gabriel Wicke <wicke@wikidev.net> |
6 | * http://wikidev.net/ |
7 | * |
8 | * Based on HistoryAction and SpecialExport |
9 | * |
10 | * This program is free software; you can redistribute it and/or modify |
11 | * it under the terms of the GNU General Public License as published by |
12 | * the Free Software Foundation; either version 2 of the License, or |
13 | * (at your option) any later version. |
14 | * |
15 | * This program is distributed in the hope that it will be useful, |
16 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
17 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
18 | * GNU General Public License for more details. |
19 | * |
20 | * You should have received a copy of the GNU General Public License along |
21 | * with this program; if not, write to the Free Software Foundation, Inc., |
22 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
23 | * http://www.gnu.org/copyleft/gpl.html |
24 | * |
25 | * @author Gabriel Wicke <wicke@wikidev.net> |
26 | * @file |
27 | */ |
28 | |
29 | use MediaWiki\Content\TextContent; |
30 | use MediaWiki\Context\IContextSource; |
31 | use MediaWiki\Logger\LoggerFactory; |
32 | use MediaWiki\MainConfigNames; |
33 | use MediaWiki\Parser\Parser; |
34 | use MediaWiki\Parser\ParserOptions; |
35 | use MediaWiki\Permissions\PermissionManager; |
36 | use MediaWiki\Permissions\RestrictionStore; |
37 | use MediaWiki\Revision\RevisionLookup; |
38 | use MediaWiki\Revision\SlotRecord; |
39 | use MediaWiki\Session\SessionManager; |
40 | use MediaWiki\User\UserFactory; |
41 | use MediaWiki\User\UserRigorOptions; |
42 | |
43 | /** |
44 | * A simple method to retrieve the plain source of an article, |
45 | * using "action=raw" in the GET request string. |
46 | * |
47 | * @ingroup Actions |
48 | */ |
49 | class RawAction extends FormlessAction { |
50 | |
51 | private Parser $parser; |
52 | private PermissionManager $permissionManager; |
53 | private RevisionLookup $revisionLookup; |
54 | private RestrictionStore $restrictionStore; |
55 | private UserFactory $userFactory; |
56 | |
57 | /** |
58 | * @param Article $article |
59 | * @param IContextSource $context |
60 | * @param Parser $parser |
61 | * @param PermissionManager $permissionManager |
62 | * @param RevisionLookup $revisionLookup |
63 | * @param RestrictionStore $restrictionStore |
64 | * @param UserFactory $userFactory |
65 | */ |
66 | public function __construct( |
67 | Article $article, |
68 | IContextSource $context, |
69 | Parser $parser, |
70 | PermissionManager $permissionManager, |
71 | RevisionLookup $revisionLookup, |
72 | RestrictionStore $restrictionStore, |
73 | UserFactory $userFactory |
74 | ) { |
75 | parent::__construct( $article, $context ); |
76 | $this->parser = $parser; |
77 | $this->permissionManager = $permissionManager; |
78 | $this->revisionLookup = $revisionLookup; |
79 | $this->restrictionStore = $restrictionStore; |
80 | $this->userFactory = $userFactory; |
81 | } |
82 | |
83 | /** @inheritDoc */ |
84 | public function getName() { |
85 | return 'raw'; |
86 | } |
87 | |
88 | public function requiresWrite() { |
89 | return false; |
90 | } |
91 | |
92 | public function requiresUnblock() { |
93 | return false; |
94 | } |
95 | |
96 | /** |
97 | * @suppress SecurityCheck-XSS Non html mime type |
98 | * @return string|null |
99 | */ |
100 | public function onView() { |
101 | $this->getOutput()->disable(); |
102 | $request = $this->getRequest(); |
103 | $response = $request->response(); |
104 | $config = $this->context->getConfig(); |
105 | |
106 | if ( $this->getOutput()->checkLastModified( |
107 | $this->getWikiPage()->getTouched() |
108 | ) ) { |
109 | // Client cache fresh and headers sent, nothing more to do. |
110 | return null; |
111 | } |
112 | |
113 | $contentType = $this->getContentType(); |
114 | |
115 | $maxage = $request->getInt( 'maxage', $config->get( MainConfigNames::CdnMaxAge ) ); |
116 | $smaxage = $request->getIntOrNull( 'smaxage' ); |
117 | if ( $smaxage === null ) { |
118 | if ( |
119 | $contentType === 'text/css' || |
120 | $contentType === 'application/json' || |
121 | $contentType === 'text/javascript' |
122 | ) { |
123 | // CSS/JSON/JS raw content has its own CDN max age configuration. |
124 | // Note: HTMLCacheUpdater::getUrls() includes action=raw for css/json/js |
125 | // pages, so if using the canonical url, this will get HTCP purges. |
126 | $smaxage = intval( $config->get( MainConfigNames::ForcedRawSMaxage ) ); |
127 | } else { |
128 | // No CDN cache for anything else |
129 | $smaxage = 0; |
130 | } |
131 | } |
132 | |
133 | // Set standard Vary headers so cache varies on cookies and such (T125283) |
134 | $response->header( $this->getOutput()->getVaryHeader() ); |
135 | |
136 | // Output may contain user-specific data; |
137 | // vary generated content for open sessions on private wikis |
138 | $privateCache = !$this->permissionManager->isEveryoneAllowed( 'read' ) && |
139 | ( $smaxage === 0 || SessionManager::getGlobalSession()->isPersistent() ); |
140 | // Don't accidentally cache cookies if the user is registered (T55032) |
141 | $privateCache = $privateCache || $this->getUser()->isRegistered(); |
142 | $mode = $privateCache ? 'private' : 'public'; |
143 | $response->header( |
144 | 'Cache-Control: ' . $mode . ', s-maxage=' . $smaxage . ', max-age=' . $maxage |
145 | ); |
146 | |
147 | // In the event of user JS, don't allow loading a user JS/CSS/Json |
148 | // subpage that has no registered user associated with, as |
149 | // someone could register the account and take control of the |
150 | // JS/CSS/Json page. |
151 | $title = $this->getTitle(); |
152 | if ( $title->isUserConfigPage() && $contentType !== 'text/x-wiki' ) { |
153 | // not using getRootText() as we want this to work |
154 | // even if subpages are disabled. |
155 | $rootPage = strtok( $title->getText(), '/' ); |
156 | $userFromTitle = $this->userFactory->newFromName( $rootPage, UserRigorOptions::RIGOR_USABLE ); |
157 | if ( !$userFromTitle || !$userFromTitle->isRegistered() ) { |
158 | $elevated = $this->getAuthority()->isAllowed( 'editinterface' ); |
159 | $elevatedText = $elevated ? 'by elevated ' : ''; |
160 | $log = LoggerFactory::getInstance( "security" ); |
161 | $log->warning( |
162 | "Unsafe JS/CSS/Json {$elevatedText}load - {user} loaded {title} with {ctype}", |
163 | [ |
164 | 'user' => $this->getUser()->getName(), |
165 | 'title' => $title->getPrefixedDBkey(), |
166 | 'ctype' => $contentType, |
167 | 'elevated' => $elevated |
168 | ] |
169 | ); |
170 | throw new HttpError( 403, wfMessage( 'unregistered-user-config' ) ); |
171 | } |
172 | } |
173 | |
174 | // Don't allow loading non-protected pages as javascript. |
175 | // In the future, we may further restrict this to only CONTENT_MODEL_JAVASCRIPT |
176 | // in NS_MEDIAWIKI or NS_USER, as well as including other config types, |
177 | // but for now be more permissive. Allowing protected pages outside |
178 | // NS_USER and NS_MEDIAWIKI in particular should be considered a temporary |
179 | // allowance. |
180 | $pageRestrictions = $this->restrictionStore->getRestrictions( $title, 'edit' ); |
181 | if ( |
182 | $contentType === 'text/javascript' && |
183 | !$title->isUserJsConfigPage() && |
184 | !$title->inNamespace( NS_MEDIAWIKI ) && |
185 | !in_array( 'sysop', $pageRestrictions ) && |
186 | !in_array( 'editprotected', $pageRestrictions ) |
187 | ) { |
188 | |
189 | $log = LoggerFactory::getInstance( "security" ); |
190 | $log->info( "Blocked loading unprotected JS {title} for {user}", |
191 | [ |
192 | 'user' => $this->getUser()->getName(), |
193 | 'title' => $title->getPrefixedDBkey(), |
194 | ] |
195 | ); |
196 | throw new HttpError( 403, wfMessage( 'unprotected-js' ) ); |
197 | } |
198 | |
199 | $response->header( 'Content-type: ' . $contentType . '; charset=UTF-8' ); |
200 | |
201 | $text = $this->getRawText(); |
202 | |
203 | // Don't return a 404 response for CSS or JavaScript; |
204 | // 404s aren't generally cached, and it would create |
205 | // extra hits when user CSS/JS are on and the user doesn't |
206 | // have the pages. |
207 | if ( $text === false && $contentType === 'text/x-wiki' ) { |
208 | $response->statusHeader( 404 ); |
209 | } |
210 | |
211 | if ( !$this->getHookRunner()->onRawPageViewBeforeOutput( $this, $text ) ) { |
212 | wfDebug( __METHOD__ . ": RawPageViewBeforeOutput hook broke raw page output." ); |
213 | } |
214 | |
215 | echo $text; |
216 | |
217 | return null; |
218 | } |
219 | |
220 | /** |
221 | * Get the text that should be returned, or false if the page or revision |
222 | * was not found. |
223 | * |
224 | * @return string|false |
225 | */ |
226 | public function getRawText() { |
227 | $text = false; |
228 | $title = $this->getTitle(); |
229 | $request = $this->getRequest(); |
230 | |
231 | // Get it from the DB |
232 | $rev = $this->revisionLookup->getRevisionByTitle( $title, $this->getOldId() ); |
233 | if ( $rev ) { |
234 | $lastMod = wfTimestamp( TS_RFC2822, $rev->getTimestamp() ); |
235 | $request->response()->header( "Last-modified: $lastMod" ); |
236 | |
237 | // Public-only due to cache headers |
238 | // Fetch specific slot if defined |
239 | $slot = $this->getRequest()->getText( 'slot' ); |
240 | if ( $slot ) { |
241 | if ( $rev->hasSlot( $slot ) ) { |
242 | $content = $rev->getContent( $slot ); |
243 | } else { |
244 | $content = null; |
245 | } |
246 | } else { |
247 | $content = $rev->getContent( SlotRecord::MAIN ); |
248 | } |
249 | |
250 | if ( $content === null ) { |
251 | // revision or slot was not found (or suppressed) |
252 | } elseif ( !$content instanceof TextContent && !method_exists( $content, 'getText' ) ) { |
253 | // non-text content |
254 | wfHttpError( |
255 | 415, |
256 | "Unsupported Media Type", "The requested page uses the content model `" |
257 | . $content->getModel() . "` which is not supported via this interface." |
258 | ); |
259 | die(); |
260 | } else { |
261 | // want a section? |
262 | $section = $request->getIntOrNull( 'section' ); |
263 | if ( $section !== null ) { |
264 | $content = $content->getSection( $section ); |
265 | } |
266 | |
267 | if ( $content !== null && $content !== false ) { |
268 | // section found (and section supported, e.g. not for JS, JSON, and CSS) |
269 | $text = $content->getText(); |
270 | } |
271 | } |
272 | } |
273 | |
274 | if ( $text !== false && $text !== '' && $request->getRawVal( 'templates' ) === 'expand' ) { |
275 | $text = $this->parser->preprocess( |
276 | $text, |
277 | $title, |
278 | ParserOptions::newFromContext( $this->getContext() ) |
279 | ); |
280 | } |
281 | |
282 | return $text; |
283 | } |
284 | |
285 | /** |
286 | * Get the ID of the revision that should be used to get the text. |
287 | * |
288 | * @return int |
289 | */ |
290 | public function getOldId() { |
291 | $oldId = $this->getRequest()->getInt( 'oldid' ); |
292 | $rl = $this->revisionLookup; |
293 | switch ( $this->getRequest()->getText( 'direction' ) ) { |
294 | case 'next': |
295 | # output next revision, or nothing if there isn't one |
296 | $nextRev = null; |
297 | if ( $oldId ) { |
298 | $oldRev = $rl->getRevisionById( $oldId ); |
299 | if ( $oldRev ) { |
300 | $nextRev = $rl->getNextRevision( $oldRev ); |
301 | } |
302 | } |
303 | $oldId = $nextRev ? $nextRev->getId() : -1; |
304 | break; |
305 | case 'prev': |
306 | # output previous revision, or nothing if there isn't one |
307 | $prevRev = null; |
308 | if ( !$oldId ) { |
309 | # get the current revision so we can get the penultimate one |
310 | $oldId = $this->getWikiPage()->getLatest(); |
311 | } |
312 | $oldRev = $rl->getRevisionById( $oldId ); |
313 | if ( $oldRev ) { |
314 | $prevRev = $rl->getPreviousRevision( $oldRev ); |
315 | } |
316 | $oldId = $prevRev ? $prevRev->getId() : -1; |
317 | break; |
318 | case 'cur': |
319 | $oldId = 0; |
320 | break; |
321 | } |
322 | |
323 | // @phan-suppress-next-line PhanTypeMismatchReturnNullable RevisionRecord::getId does not return null here |
324 | return $oldId; |
325 | } |
326 | |
327 | /** |
328 | * Get the content type to be used for the response |
329 | * |
330 | * @return string |
331 | */ |
332 | public function getContentType() { |
333 | // Optimisation: Avoid slow getVal(), this isn't user-generated content. |
334 | $ctype = $this->getRequest()->getRawVal( 'ctype' ); |
335 | |
336 | if ( $ctype == '' ) { |
337 | // Legacy compatibility |
338 | $gen = $this->getRequest()->getRawVal( 'gen' ); |
339 | if ( $gen == 'js' ) { |
340 | $ctype = 'text/javascript'; |
341 | } elseif ( $gen == 'css' ) { |
342 | $ctype = 'text/css'; |
343 | } |
344 | } |
345 | |
346 | static $allowedCTypes = [ |
347 | 'text/x-wiki', |
348 | 'text/javascript', |
349 | 'text/css', |
350 | // FIXME: Should we still allow Zope editing? External editing feature was dropped |
351 | 'application/x-zope-edit', |
352 | 'application/json' |
353 | ]; |
354 | if ( $ctype == '' || !in_array( $ctype, $allowedCTypes ) ) { |
355 | $ctype = 'text/x-wiki'; |
356 | } |
357 | |
358 | return $ctype; |
359 | } |
360 | } |