Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 148
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
RawAction
0.00% covered (danger)
0.00%
0 / 148
0.00% covered (danger)
0.00%
0 / 8
3192
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requiresWrite
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requiresUnblock
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onView
0.00% covered (danger)
0.00%
0 / 65
0.00% covered (danger)
0.00%
0 / 1
552
 getRawText
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
182
 getOldId
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
110
 getContentType
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
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
29use MediaWiki\Content\TextContent;
30use MediaWiki\Context\IContextSource;
31use MediaWiki\Logger\LoggerFactory;
32use MediaWiki\MainConfigNames;
33use MediaWiki\Parser\Parser;
34use MediaWiki\Parser\ParserOptions;
35use MediaWiki\Permissions\PermissionManager;
36use MediaWiki\Permissions\RestrictionStore;
37use MediaWiki\Revision\RevisionLookup;
38use MediaWiki\Revision\SlotRecord;
39use MediaWiki\Session\SessionManager;
40use MediaWiki\User\UserFactory;
41use 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 */
49class 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}