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