Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
59.81% |
189 / 316 |
|
33.33% |
5 / 15 |
CRAP | |
0.00% |
0 / 1 |
ActionEntryPoint | |
59.81% |
189 / 316 |
|
33.33% |
5 / 15 |
1209.26 | |
0.00% |
0 / 1 |
getContext | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getOutput | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getUser | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
handleTopLevelError | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
56 | |||
execute | |
59.38% |
19 / 32 |
|
0.00% |
0 / 1 |
12.29 | |||
maybeDoHttpsRedirect | |
13.33% |
2 / 15 |
|
0.00% |
0 / 1 |
8.86 | |||
doPrepareForOutput | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
schedulePostSendJobs | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
parseTitle | |
95.65% |
44 / 46 |
|
0.00% |
0 / 1 |
29 | |||
getTitle | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
getAction | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
performRequest | |
59.04% |
49 / 83 |
|
0.00% |
0 / 1 |
77.11 | |||
tryNormaliseRedirect | |
46.15% |
18 / 39 |
|
0.00% |
0 / 1 |
39.38 | |||
initializeArticle | |
56.76% |
21 / 37 |
|
0.00% |
0 / 1 |
61.14 | |||
performAction | |
65.71% |
23 / 35 |
|
0.00% |
0 / 1 |
14.03 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Actions; |
4 | |
5 | use Action; |
6 | use Article; |
7 | use BadTitleError; |
8 | use ErrorPageError; |
9 | use HTMLFileCache; |
10 | use HttpError; |
11 | use MediaWiki\Context\RequestContext; |
12 | use MediaWiki\Logger\LoggerFactory; |
13 | use MediaWiki\MainConfigNames; |
14 | use MediaWiki\MediaWikiEntryPoint; |
15 | use MediaWiki\Output\OutputPage; |
16 | use MediaWiki\Permissions\PermissionStatus; |
17 | use MediaWiki\Profiler\ProfilingContext; |
18 | use MediaWiki\Request\DerivativeRequest; |
19 | use MediaWiki\Request\WebRequest; |
20 | use MediaWiki\SpecialPage\RedirectSpecialPage; |
21 | use MediaWiki\SpecialPage\SpecialPage; |
22 | use MediaWiki\Title\MalformedTitleException; |
23 | use MediaWiki\Title\Title; |
24 | use MediaWiki\User\User; |
25 | use MWExceptionRenderer; |
26 | use PermissionsError; |
27 | use Profiler; |
28 | use Throwable; |
29 | use UnexpectedValueException; |
30 | use ViewAction; |
31 | use WikiFilePage; |
32 | use Wikimedia\Rdbms\DBConnectionError; |
33 | |
34 | /** |
35 | * The index.php entry point for web browser navigations, usually routed to |
36 | * an Action or SpecialPage subclass. |
37 | * |
38 | * @internal For use in index.php |
39 | * @ingroup entrypoint |
40 | */ |
41 | class ActionEntryPoint extends MediaWikiEntryPoint { |
42 | |
43 | /** |
44 | * Overwritten to narrow the return type to RequestContext |
45 | * @return RequestContext |
46 | */ |
47 | protected function getContext(): RequestContext { |
48 | /** @var RequestContext $context */ |
49 | $context = parent::getContext(); |
50 | |
51 | // @phan-suppress-next-line PhanTypeMismatchReturnSuperType see $context in the constructor |
52 | return $context; |
53 | } |
54 | |
55 | protected function getOutput(): OutputPage { |
56 | return $this->getContext()->getOutput(); |
57 | } |
58 | |
59 | protected function getUser(): User { |
60 | return $this->getContext()->getUser(); |
61 | } |
62 | |
63 | protected function handleTopLevelError( Throwable $e ) { |
64 | $context = $this->getContext(); |
65 | $action = $context->getRequest()->getRawVal( 'action', 'view' ); |
66 | if ( |
67 | $e instanceof DBConnectionError && |
68 | $context->hasTitle() && |
69 | $context->getTitle()->canExist() && |
70 | in_array( $action, [ 'view', 'history' ], true ) && |
71 | HTMLFileCache::useFileCache( $context, HTMLFileCache::MODE_OUTAGE ) |
72 | ) { |
73 | // Try to use any (even stale) file during outages... |
74 | $cache = new HTMLFileCache( $context->getTitle(), $action ); |
75 | if ( $cache->isCached() ) { |
76 | $cache->loadFromFileCache( $context, HTMLFileCache::MODE_OUTAGE ); |
77 | $this->print( MWExceptionRenderer::getHTML( $e ) ); |
78 | $this->exit(); |
79 | } |
80 | } |
81 | |
82 | parent::handleTopLevelError( $e ); |
83 | } |
84 | |
85 | /** |
86 | * Determine and send the response headers and body for this web request |
87 | */ |
88 | protected function execute() { |
89 | global $wgTitle; |
90 | |
91 | // Get title from request parameters, |
92 | // is set on the fly by parseTitle the first time. |
93 | $title = $this->getTitle(); |
94 | $wgTitle = $title; |
95 | |
96 | $request = $this->getContext()->getRequest(); |
97 | // Set DB query expectations for this HTTP request |
98 | $trxLimits = $this->getConfig( MainConfigNames::TrxProfilerLimits ); |
99 | $trxProfiler = Profiler::instance()->getTransactionProfiler(); |
100 | $trxProfiler->setLogger( LoggerFactory::getInstance( 'rdbms' ) ); |
101 | $trxProfiler->setStatsdDataFactory( $this->getStatsdDataFactory() ); |
102 | $trxProfiler->setRequestMethod( $request->getMethod() ); |
103 | if ( $request->hasSafeMethod() ) { |
104 | $trxProfiler->setExpectations( $trxLimits['GET'], __METHOD__ ); |
105 | } else { |
106 | $trxProfiler->setExpectations( $trxLimits['POST'], __METHOD__ ); |
107 | } |
108 | |
109 | if ( $this->maybeDoHttpsRedirect() ) { |
110 | return; |
111 | } |
112 | |
113 | $context = $this->getContext(); |
114 | $output = $context->getOutput(); |
115 | |
116 | // NOTE: HTMLFileCache::useFileCache() is not used in WMF production but is |
117 | // here to provide third-party wikis with a way to enable caching for |
118 | // "view" and "history" actions. It's triggered by the use of $wgUseFileCache |
119 | // when set to true in LocalSettings.php. |
120 | if ( $title->canExist() && HTMLFileCache::useFileCache( $context ) ) { |
121 | // getAction() may trigger DB queries, so avoid eagerly initializing it if possible. |
122 | // This reduces the cost of requests that exit early due to tryNormaliseRedirect() |
123 | // or a MediaWikiPerformAction / BeforeInitialize hook handler. |
124 | $action = $this->getAction(); |
125 | // Try low-level file cache hit |
126 | $cache = new HTMLFileCache( $title, $action ); |
127 | if ( $cache->isCacheGood( /* Assume up to date */ ) ) { |
128 | // Check incoming headers to see if client has this cached |
129 | $timestamp = $cache->cacheTimestamp(); |
130 | if ( !$output->checkLastModified( $timestamp ) ) { |
131 | $cache->loadFromFileCache( $context ); |
132 | } |
133 | // Do any stats increment/watchlist stuff, assuming user is viewing the |
134 | // latest revision (which should always be the case for file cache) |
135 | $context->getWikiPage()->doViewUpdates( $context->getAuthority() ); |
136 | // Tell OutputPage that output is taken care of |
137 | $output->disable(); |
138 | |
139 | return; |
140 | } |
141 | } |
142 | |
143 | try { |
144 | // Actually do the work of the request and build up any output |
145 | $this->performRequest(); |
146 | } catch ( ErrorPageError $e ) { |
147 | // TODO: Should ErrorPageError::report accept a OutputPage parameter? |
148 | $e->report( ErrorPageError::STAGE_OUTPUT ); |
149 | $output->considerCacheSettingsFinal(); |
150 | // T64091: while exceptions are convenient to bubble up GUI errors, |
151 | // they are not internal application faults. As with normal requests, this |
152 | // should commit, print the output, do deferred updates, jobs, and profiling. |
153 | } |
154 | |
155 | $this->prepareForOutput(); |
156 | |
157 | // Ask OutputPage/Skin to stage the output (HTTP response body and headers). |
158 | // Flush the output to the client unless an exception occurred. |
159 | // Note that the OutputPage object in $context may have been replaced, |
160 | // so better fetch it again here. |
161 | $output = $context->getOutput(); |
162 | $this->outputResponsePayload( $output->output( true ) ); |
163 | } |
164 | |
165 | /** |
166 | * If the stars are suitably aligned, do an HTTP->HTTPS redirect |
167 | * |
168 | * Note: Do this after $wgTitle is setup, otherwise the hooks run from |
169 | * isRegistered() will do all sorts of weird stuff. |
170 | * |
171 | * @return bool True if the redirect was done. Handling of the request |
172 | * should be aborted. False if no redirect was done. |
173 | */ |
174 | protected function maybeDoHttpsRedirect() { |
175 | if ( !$this->shouldDoHttpRedirect() ) { |
176 | return false; |
177 | } |
178 | |
179 | $context = $this->getContext(); |
180 | $request = $context->getRequest(); |
181 | $oldUrl = $request->getFullRequestURL(); |
182 | $redirUrl = preg_replace( '#^http://#', 'https://', $oldUrl ); |
183 | |
184 | if ( $request->wasPosted() ) { |
185 | // This is weird and we'd hope it almost never happens. This |
186 | // means that a POST came in via HTTP and policy requires us |
187 | // redirecting to HTTPS. It's likely such a request is going |
188 | // to fail due to post data being lost, but let's try anyway |
189 | // and just log the instance. |
190 | |
191 | // @todo FIXME: See if we could issue a 307 or 308 here, need |
192 | // to see how clients (automated & browser) behave when we do |
193 | wfDebugLog( 'RedirectedPosts', "Redirected from HTTP to HTTPS: $oldUrl" ); |
194 | } |
195 | // Setup dummy Title, otherwise OutputPage::redirect will fail |
196 | $title = Title::newFromText( 'REDIR', NS_MAIN ); |
197 | $context->setTitle( $title ); |
198 | // Since we only do this redir to change proto, always send a vary header |
199 | $output = $context->getOutput(); |
200 | $output->addVaryHeader( 'X-Forwarded-Proto' ); |
201 | $output->redirect( $redirUrl ); |
202 | $output->output(); |
203 | |
204 | return true; |
205 | } |
206 | |
207 | protected function doPrepareForOutput() { |
208 | parent::doPrepareForOutput(); |
209 | |
210 | // If needed, push a deferred update to run jobs after the output is sent |
211 | $this->schedulePostSendJobs(); |
212 | } |
213 | |
214 | protected function schedulePostSendJobs() { |
215 | // Recursion guard for $wgRunJobsAsync |
216 | if ( $this->getTitle()->isSpecial( 'RunJobs' ) ) { |
217 | return; |
218 | } |
219 | |
220 | parent::schedulePostSendJobs(); |
221 | } |
222 | |
223 | /** |
224 | * Parse the request to get the Title object |
225 | * |
226 | * @throws MalformedTitleException If a title has been provided by the user, but is invalid. |
227 | * @param WebRequest $request |
228 | * @return Title Title object to be $wgTitle |
229 | */ |
230 | protected function parseTitle( $request ) { |
231 | $curid = $request->getInt( 'curid' ); |
232 | $title = $request->getText( 'title' ); |
233 | |
234 | $ret = null; |
235 | if ( $curid ) { |
236 | // URLs like this are generated by RC, because rc_title isn't always accurate |
237 | $ret = Title::newFromID( $curid ); |
238 | } |
239 | if ( $ret === null ) { |
240 | $ret = Title::newFromURL( $title ); |
241 | if ( $ret !== null ) { |
242 | // Alias NS_MEDIA page URLs to NS_FILE...we only use NS_MEDIA |
243 | // in wikitext links to tell Parser to make a direct file link |
244 | if ( $ret->getNamespace() === NS_MEDIA ) { |
245 | $ret = Title::makeTitle( NS_FILE, $ret->getDBkey() ); |
246 | } |
247 | // Check variant links so that interwiki links don't have to worry |
248 | // about the possible different language variants |
249 | $services = $this->getServiceContainer(); |
250 | $languageConverter = $services |
251 | ->getLanguageConverterFactory() |
252 | ->getLanguageConverter( $services->getContentLanguage() ); |
253 | if ( $languageConverter->hasVariants() && !$ret->exists() ) { |
254 | $languageConverter->findVariantLink( $title, $ret ); |
255 | } |
256 | } |
257 | } |
258 | |
259 | // If title is not provided, always allow oldid and diff to set the title. |
260 | // If title is provided, allow oldid and diff to override the title, unless |
261 | // we are talking about a special page which might use these parameters for |
262 | // other purposes. |
263 | if ( $ret === null || !$ret->isSpecialPage() ) { |
264 | // We can have urls with just ?diff=,?oldid= or even just ?diff= |
265 | $oldid = $request->getInt( 'oldid' ); |
266 | $oldid = $oldid ?: $request->getInt( 'diff' ); |
267 | // Allow oldid to override a changed or missing title |
268 | if ( $oldid ) { |
269 | $revRecord = $this->getServiceContainer() |
270 | ->getRevisionLookup() |
271 | ->getRevisionById( $oldid ); |
272 | if ( $revRecord ) { |
273 | $ret = Title::newFromLinkTarget( |
274 | $revRecord->getPageAsLinkTarget() |
275 | ); |
276 | } |
277 | } |
278 | } |
279 | |
280 | if ( $ret === null && $request->getCheck( 'search' ) ) { |
281 | // Compatibility with old search URLs which didn't use Special:Search |
282 | // Just check for presence here, so blank requests still |
283 | // show the search page when using ugly URLs (T10054). |
284 | $ret = SpecialPage::getTitleFor( 'Search' ); |
285 | } |
286 | |
287 | if ( $ret === null || !$ret->isSpecialPage() ) { |
288 | // Compatibility with old URLs for Special:RevisionDelete/Special:EditTags (T323338) |
289 | $actionName = $request->getRawVal( 'action' ); |
290 | if ( |
291 | $actionName === 'revisiondelete' || |
292 | ( $actionName === 'historysubmit' && $request->getBool( 'revisiondelete' ) ) |
293 | ) { |
294 | $ret = SpecialPage::getTitleFor( 'Revisiondelete' ); |
295 | } elseif ( |
296 | $actionName === 'editchangetags' || |
297 | ( $actionName === 'historysubmit' && $request->getBool( 'editchangetags' ) ) |
298 | ) { |
299 | $ret = SpecialPage::getTitleFor( 'EditTags' ); |
300 | } |
301 | } |
302 | |
303 | // Use the main page as default title if nothing else has been provided |
304 | if ( $ret === null |
305 | && strval( $title ) === '' |
306 | && !$request->getCheck( 'curid' ) |
307 | && $request->getRawVal( 'action' ) !== 'delete' |
308 | ) { |
309 | $ret = Title::newMainPage(); |
310 | } |
311 | |
312 | if ( $ret === null || ( $ret->getDBkey() == '' && !$ret->isExternal() ) ) { |
313 | // If we get here, we definitely don't have a valid title; throw an exception. |
314 | // Try to get detailed invalid title exception first, fall back to MalformedTitleException. |
315 | Title::newFromTextThrow( $title ); |
316 | throw new MalformedTitleException( 'badtitletext', $title ); |
317 | } |
318 | |
319 | return $ret; |
320 | } |
321 | |
322 | /** |
323 | * Get the Title object that we'll be acting on, as specified in the WebRequest |
324 | * @return Title |
325 | */ |
326 | public function getTitle() { |
327 | $context = $this->getContext(); |
328 | |
329 | if ( !$context->hasTitle() ) { |
330 | try { |
331 | $context->setTitle( $this->parseTitle( $context->getRequest() ) ); |
332 | } catch ( MalformedTitleException $ex ) { |
333 | $context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) ); |
334 | } |
335 | } |
336 | return $context->getTitle(); |
337 | } |
338 | |
339 | /** |
340 | * Returns the name of the action that will be executed. |
341 | * |
342 | * @note This is public for the benefit of extensions that implement |
343 | * the BeforeInitialize or MediaWikiPerformAction hooks. |
344 | * |
345 | * @return string Action |
346 | */ |
347 | public function getAction(): string { |
348 | return $this->getContext()->getActionName(); |
349 | } |
350 | |
351 | /** |
352 | * Performs the request. |
353 | * - bad titles |
354 | * - read restriction |
355 | * - local interwiki redirects |
356 | * - redirect loop |
357 | * - special pages |
358 | * - normal pages |
359 | * |
360 | * @throws PermissionsError|BadTitleError|HttpError |
361 | * @return void |
362 | */ |
363 | protected function performRequest() { |
364 | global $wgTitle; |
365 | |
366 | $context = $this->getContext(); |
367 | |
368 | $request = $context->getRequest(); |
369 | $output = $context->getOutput(); |
370 | |
371 | if ( $request->getRawVal( 'printable' ) === 'yes' ) { |
372 | $output->setPrintable(); |
373 | } |
374 | |
375 | $user = $context->getUser(); |
376 | $title = $context->getTitle(); |
377 | $requestTitle = $title; |
378 | |
379 | $userOptionsLookup = $this->getServiceContainer()->getUserOptionsLookup(); |
380 | if ( $userOptionsLookup->getBoolOption( $user, 'forcesafemode' ) ) { |
381 | $request->setVal( 'safemode', '1' ); |
382 | } |
383 | |
384 | $this->getHookRunner()->onBeforeInitialize( $title, null, $output, $user, $request, $this ); |
385 | |
386 | // Invalid titles. T23776: The interwikis must redirect even if the page name is empty. |
387 | if ( $title === null || ( $title->getDBkey() == '' && !$title->isExternal() ) |
388 | || $title->isSpecial( 'Badtitle' ) |
389 | ) { |
390 | $context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) ); |
391 | try { |
392 | $this->parseTitle( $request ); |
393 | } catch ( MalformedTitleException $ex ) { |
394 | throw new BadTitleError( $ex ); |
395 | } |
396 | throw new BadTitleError(); |
397 | } |
398 | |
399 | // Check user's permissions to read this page. |
400 | // We have to check here to catch special pages etc. |
401 | // We will check again in Article::view(). |
402 | $permissionStatus = PermissionStatus::newEmpty(); |
403 | if ( !$context->getAuthority()->authorizeRead( 'read', $title, $permissionStatus ) ) { |
404 | // T34276: allowing the skin to generate output with $wgTitle or |
405 | // $context->title set to the input title would allow anonymous users to |
406 | // determine whether a page exists, potentially leaking private data. In fact, the |
407 | // curid and oldid request parameters would allow page titles to be enumerated even |
408 | // when they are not guessable. So we reset the title to Special:Badtitle before the |
409 | // permissions error is displayed. |
410 | |
411 | // The skin mostly uses $context->getTitle() these days, but some extensions |
412 | // still use $wgTitle. |
413 | $badTitle = SpecialPage::getTitleFor( 'Badtitle' ); |
414 | $context->setTitle( $badTitle ); |
415 | $wgTitle = $badTitle; |
416 | |
417 | throw new PermissionsError( 'read', $permissionStatus ); |
418 | } |
419 | |
420 | // Interwiki redirects |
421 | if ( $title->isExternal() ) { |
422 | $rdfrom = $request->getVal( 'rdfrom' ); |
423 | if ( $rdfrom ) { |
424 | $url = $title->getFullURL( [ 'rdfrom' => $rdfrom ] ); |
425 | } else { |
426 | $query = $request->getValues(); |
427 | unset( $query['title'] ); |
428 | $url = $title->getFullURL( $query ); |
429 | } |
430 | // Check for a redirect loop |
431 | if ( $url !== $request->getFullRequestURL() && $title->isLocal() ) { |
432 | // 301 so google et al report the target as the actual url. |
433 | $output->redirect( $url, 301 ); |
434 | } else { |
435 | $context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) ); |
436 | try { |
437 | $this->parseTitle( $request ); |
438 | } catch ( MalformedTitleException $ex ) { |
439 | throw new BadTitleError( $ex ); |
440 | } |
441 | throw new BadTitleError(); |
442 | } |
443 | // Handle any other redirects. |
444 | // Redirect loops, titleless URL, $wgUsePathInfo URLs, and URLs with a variant |
445 | } elseif ( !$this->tryNormaliseRedirect( $title ) ) { |
446 | // Prevent information leak via Special:MyPage et al (T109724) |
447 | $spFactory = $this->getServiceContainer()->getSpecialPageFactory(); |
448 | if ( $title->isSpecialPage() ) { |
449 | $specialPage = $spFactory->getPage( $title->getDBkey() ); |
450 | if ( $specialPage instanceof RedirectSpecialPage ) { |
451 | $specialPage->setContext( $context ); |
452 | if ( $this->getConfig( MainConfigNames::HideIdentifiableRedirects ) |
453 | && $specialPage->personallyIdentifiableTarget() |
454 | ) { |
455 | [ , $subpage ] = $spFactory->resolveAlias( $title->getDBkey() ); |
456 | $target = $specialPage->getRedirect( $subpage ); |
457 | // Target can also be true. We let that case fall through to normal processing. |
458 | if ( $target instanceof Title ) { |
459 | if ( $target->isExternal() ) { |
460 | // Handle interwiki redirects |
461 | $target = SpecialPage::getTitleFor( |
462 | 'GoToInterwiki', |
463 | 'force/' . $target->getPrefixedDBkey() |
464 | ); |
465 | } |
466 | |
467 | $query = $specialPage->getRedirectQuery( $subpage ) ?: []; |
468 | $derivateRequest = new DerivativeRequest( $request, $query ); |
469 | $derivateRequest->setRequestURL( $request->getRequestURL() ); |
470 | $context->setRequest( $derivateRequest ); |
471 | // Do not varnish cache these. May vary even for anons |
472 | $output->lowerCdnMaxage( 0 ); |
473 | // NOTE: This also clears any action cache. |
474 | // Action should not have been computed yet, but if it was, |
475 | // we reset it because special pages only support "view". |
476 | $context->setTitle( $target ); |
477 | $wgTitle = $target; |
478 | $title = $target; |
479 | $output->addJsConfigVars( [ |
480 | 'wgInternalRedirectTargetUrl' => $target->getLinkURL( $query ), |
481 | ] ); |
482 | $output->addModules( 'mediawiki.action.view.redirect' ); |
483 | |
484 | // If the title is invalid, redirect but show the correct bad title error - T297407 |
485 | if ( !$target->isValid() ) { |
486 | try { |
487 | $this->getServiceContainer()->getTitleParser() |
488 | ->parseTitle( $target->getPrefixedText() ); |
489 | } catch ( MalformedTitleException $ex ) { |
490 | throw new BadTitleError( $ex ); |
491 | } |
492 | throw new BadTitleError(); |
493 | } |
494 | } |
495 | } |
496 | } |
497 | } |
498 | |
499 | // Special pages ($title may have changed since if statement above) |
500 | if ( $title->isSpecialPage() ) { |
501 | // Actions that need to be made when we have a special pages |
502 | $spFactory->executePath( $title, $context ); |
503 | } else { |
504 | // ...otherwise treat it as an article view. The article |
505 | // may still be a wikipage redirect to another article or URL. |
506 | $article = $this->initializeArticle(); |
507 | if ( is_object( $article ) ) { |
508 | $this->performAction( $article, $requestTitle ); |
509 | } elseif ( is_string( $article ) ) { |
510 | $output->redirect( $article ); |
511 | } else { |
512 | throw new UnexpectedValueException( "Shouldn't happen: MediaWiki::initializeArticle()" |
513 | . " returned neither an object nor a URL" ); |
514 | } |
515 | } |
516 | $output->considerCacheSettingsFinal(); |
517 | } |
518 | } |
519 | |
520 | /** |
521 | * Handle redirects for uncanonical title requests. |
522 | * |
523 | * Handles: |
524 | * - Redirect loops. |
525 | * - No title in URL. |
526 | * - $wgUsePathInfo URLs. |
527 | * - URLs with a variant. |
528 | * - Other non-standard URLs (as long as they have no extra query parameters). |
529 | * |
530 | * Behaviour: |
531 | * - Normalise title values: |
532 | * /wiki/Foo%20Bar -> /wiki/Foo_Bar |
533 | * - Normalise empty title: |
534 | * /wiki/ -> /wiki/Main |
535 | * /w/index.php?title= -> /wiki/Main |
536 | * - Don't redirect anything with query parameters other than 'title' or 'action=view'. |
537 | * |
538 | * @param Title $title |
539 | * @return bool True if a redirect was set. |
540 | * @throws HttpError |
541 | */ |
542 | protected function tryNormaliseRedirect( Title $title ): bool { |
543 | $request = $this->getRequest(); |
544 | $output = $this->getOutput(); |
545 | |
546 | if ( $request->getRawVal( 'action', 'view' ) != 'view' |
547 | || $request->wasPosted() |
548 | || ( $request->getCheck( 'title' ) |
549 | && $title->getPrefixedDBkey() == $request->getText( 'title' ) ) |
550 | || count( $request->getValueNames( [ 'action', 'title' ] ) ) |
551 | || !$this->getHookRunner()->onTestCanonicalRedirect( $request, $title, $output ) |
552 | ) { |
553 | return false; |
554 | } |
555 | |
556 | if ( $this->getConfig( MainConfigNames::MainPageIsDomainRoot ) && $request->getRequestURL() === '/' ) { |
557 | return false; |
558 | } |
559 | |
560 | $services = $this->getServiceContainer(); |
561 | |
562 | if ( $title->isSpecialPage() ) { |
563 | [ $name, $subpage ] = $services->getSpecialPageFactory() |
564 | ->resolveAlias( $title->getDBkey() ); |
565 | |
566 | if ( $name ) { |
567 | $title = SpecialPage::getTitleFor( $name, $subpage ); |
568 | } |
569 | } |
570 | // Redirect to canonical url, make it a 301 to allow caching |
571 | $targetUrl = (string)$services->getUrlUtils()->expand( $title->getFullURL(), PROTO_CURRENT ); |
572 | if ( $targetUrl == $request->getFullRequestURL() ) { |
573 | $message = "Redirect loop detected!\n\n" . |
574 | "This means the wiki got confused about what page was " . |
575 | "requested; this sometimes happens when moving a wiki " . |
576 | "to a new server or changing the server configuration.\n\n"; |
577 | |
578 | if ( $this->getConfig( MainConfigNames::UsePathInfo ) ) { |
579 | $message .= "The wiki is trying to interpret the page " . |
580 | "title from the URL path portion (PATH_INFO), which " . |
581 | "sometimes fails depending on the web server. Try " . |
582 | "setting \"\$wgUsePathInfo = false;\" in your " . |
583 | "LocalSettings.php, or check that \$wgArticlePath " . |
584 | "is correct."; |
585 | } else { |
586 | $message .= "Your web server was detected as possibly not " . |
587 | "supporting URL path components (PATH_INFO) correctly; " . |
588 | "check your LocalSettings.php for a customized " . |
589 | "\$wgArticlePath setting and/or toggle \$wgUsePathInfo " . |
590 | "to true."; |
591 | } |
592 | throw new HttpError( 500, $message ); |
593 | } |
594 | $output->setCdnMaxage( 1200 ); |
595 | $output->redirect( $targetUrl, '301' ); |
596 | return true; |
597 | } |
598 | |
599 | /** |
600 | * Initialize the main Article object for "standard" actions (view, etc) |
601 | * Create an Article object for the page, following redirects if needed. |
602 | * |
603 | * @return Article|string An Article, or a string to redirect to another URL |
604 | */ |
605 | protected function initializeArticle() { |
606 | $context = $this->getContext(); |
607 | |
608 | $title = $context->getTitle(); |
609 | $services = $this->getServiceContainer(); |
610 | if ( $context->canUseWikiPage() ) { |
611 | // Optimization: Reuse the WikiPage instance from context, to avoid |
612 | // repeat fetching or computation of data already loaded. |
613 | $page = $context->getWikiPage(); |
614 | } else { |
615 | // This case should not happen, but just in case. |
616 | // @TODO: remove this or use an exception |
617 | $page = $services->getWikiPageFactory()->newFromTitle( $title ); |
618 | $context->setWikiPage( $page ); |
619 | wfWarn( "RequestContext::canUseWikiPage() returned false" ); |
620 | } |
621 | |
622 | // Make GUI wrapper for the WikiPage |
623 | $article = Article::newFromWikiPage( $page, $context ); |
624 | |
625 | // Skip some unnecessary code if the content model doesn't support redirects |
626 | // Use the page content model rather than invoking Title::getContentModel() |
627 | // to avoid querying page data twice (T206498) |
628 | if ( !$page->getContentHandler()->supportsRedirects() ) { |
629 | return $article; |
630 | } |
631 | |
632 | $request = $context->getRequest(); |
633 | |
634 | // Namespace might change when using redirects |
635 | // Check for redirects ... |
636 | $action = $request->getRawVal( 'action', 'view' ); |
637 | $file = ( $page instanceof WikiFilePage ) ? $page->getFile() : null; |
638 | if ( ( $action == 'view' || $action == 'render' ) // ... for actions that show content |
639 | && !$request->getCheck( 'oldid' ) // ... and are not old revisions |
640 | && !$request->getCheck( 'diff' ) // ... and not when showing diff |
641 | && $request->getRawVal( 'redirect' ) !== 'no' // ... unless explicitly told not to |
642 | // ... and the article is not a non-redirect image page with associated file |
643 | && !( is_object( $file ) && $file->exists() && !$file->getRedirected() ) |
644 | ) { |
645 | // Give extensions a change to ignore/handle redirects as needed |
646 | $ignoreRedirect = $target = false; |
647 | |
648 | $this->getHookRunner()->onInitializeArticleMaybeRedirect( $title, $request, |
649 | // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args |
650 | $ignoreRedirect, $target, $article ); |
651 | $page = $article->getPage(); // reflect any hook changes |
652 | |
653 | // Follow redirects only for... redirects. |
654 | // If $target is set, then a hook wanted to redirect. |
655 | if ( !$ignoreRedirect && ( $target || $page->isRedirect() ) ) { |
656 | // Is the target already set by an extension? |
657 | $target = $target ?: $page->followRedirect(); |
658 | if ( is_string( $target ) && !$this->getConfig( MainConfigNames::DisableHardRedirects ) ) { |
659 | // we'll need to redirect |
660 | return $target; |
661 | } |
662 | if ( is_object( $target ) ) { |
663 | // Rewrite environment to redirected article |
664 | $rpage = $services->getWikiPageFactory()->newFromTitle( $target ); |
665 | $rpage->loadPageData(); |
666 | if ( $rpage->exists() || ( is_object( $file ) && !$file->isLocal() ) ) { |
667 | $rarticle = Article::newFromWikiPage( $rpage, $context ); |
668 | $rarticle->setRedirectedFrom( $title ); |
669 | |
670 | $article = $rarticle; |
671 | // NOTE: This also clears any action cache |
672 | $context->setTitle( $target ); |
673 | $context->setWikiPage( $article->getPage() ); |
674 | } |
675 | } |
676 | } |
677 | } |
678 | |
679 | return $article; |
680 | } |
681 | |
682 | /** |
683 | * Perform one of the "standard" actions |
684 | * |
685 | * @param Article $article |
686 | * @param Title $requestTitle The original title, before any redirects were applied |
687 | */ |
688 | protected function performAction( Article $article, Title $requestTitle ) { |
689 | $request = $this->getRequest(); |
690 | $output = $this->getOutput(); |
691 | $title = $this->getTitle(); |
692 | $user = $this->getUser(); |
693 | |
694 | if ( !$this->getHookRunner()->onMediaWikiPerformAction( |
695 | $output, $article, $title, $user, $request, $this ) |
696 | ) { |
697 | return; |
698 | } |
699 | |
700 | $t = microtime( true ); |
701 | $actionName = $this->getAction(); |
702 | $services = $this->getServiceContainer(); |
703 | |
704 | $action = $services->getActionFactory()->getAction( $actionName, $article, $this->getContext() ); |
705 | if ( $action instanceof Action ) { |
706 | ProfilingContext::singleton()->init( MW_ENTRY_POINT, $actionName ); |
707 | |
708 | // Check read permissions |
709 | if ( $action->needsReadRights() && !$user->isAllowed( 'read' ) ) { |
710 | throw new PermissionsError( 'read' ); |
711 | } |
712 | |
713 | // Narrow DB query expectations for this HTTP request |
714 | if ( $request->wasPosted() && !$action->doesWrites() ) { |
715 | $trxProfiler = Profiler::instance()->getTransactionProfiler(); |
716 | $trxLimits = $this->getConfig( MainConfigNames::TrxProfilerLimits ); |
717 | $trxProfiler->setExpectations( $trxLimits['POST-nonwrite'], __METHOD__ ); |
718 | } |
719 | |
720 | // Let CDN cache things if we can purge them. |
721 | // Also unconditionally cache page views. |
722 | if ( $this->getConfig( MainConfigNames::UseCdn ) ) { |
723 | $htmlCacheUpdater = $services->getHtmlCacheUpdater(); |
724 | if ( $request->matchURLForCDN( $htmlCacheUpdater->getUrls( $requestTitle ) ) ) { |
725 | $output->setCdnMaxage( $this->getConfig( MainConfigNames::CdnMaxAge ) ); |
726 | } elseif ( $action instanceof ViewAction ) { |
727 | $output->setCdnMaxage( 3600 ); |
728 | } |
729 | } |
730 | |
731 | $action->show(); |
732 | |
733 | $runTime = microtime( true ) - $t; |
734 | |
735 | $statAction = strtr( $actionName, '.', '_' ); |
736 | $services->getStatsFactory()->getTiming( 'action_executeTiming_seconds' ) |
737 | ->setLabel( 'action', $statAction ) |
738 | ->copyToStatsdAt( 'action.' . $statAction . '.executeTiming' ) |
739 | ->observe( 1000 * $runTime ); |
740 | |
741 | return; |
742 | } |
743 | |
744 | // If we've not found out which action it is by now, it's unknown |
745 | $output->setStatusCode( 404 ); |
746 | $output->showErrorPage( 'nosuchaction', 'nosuchactiontext' ); |
747 | } |
748 | } |