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