Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.23% covered (warning)
84.23%
753 / 894
55.36% covered (warning)
55.36%
31 / 56
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiMain
84.23% covered (warning)
84.23%
753 / 894
55.36% covered (warning)
55.36%
31 / 56
534.37
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
66 / 66
100.00% covered (success)
100.00%
1 / 1
13
 isInternalMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getResult
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lacksSameOriginSecurity
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 getErrorFormatter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContinuationManager
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setContinuationManager
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getParamValidator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModule
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPrinter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCacheMaxAge
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 setCacheMode
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 getCacheMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCacheControl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createPrinterByName
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
 execute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 executeActionWithErrorHandling
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
8.02
 handleException
76.67% covered (warning)
76.67%
23 / 30
0.00% covered (danger)
0.00%
0 / 1
8.81
 handleApiBeforeMainException
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 handleCORS
46.43% covered (danger)
46.43%
26 / 56
0.00% covered (danger)
0.00%
0 / 1
38.98
 matchRequestedHeaders
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 sendCacheHeaders
65.38% covered (warning)
65.38%
34 / 52
0.00% covered (danger)
0.00%
0 / 1
44.94
 createErrorPrinter
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 errorMessagesFromException
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
7.07
 substituteResultWithError
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
12
 addRequestedFields
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 setupExecuteAction
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setupModule
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 getMaxLag
57.14% covered (warning)
57.14%
12 / 21
0.00% covered (danger)
0.00%
0 / 1
3.71
 checkMaxLag
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 checkConditionalRequestHeaders
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
21
 checkExecutePermissions
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 checkReadOnly
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
5.93
 checkBotReadOnly
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 checkAsserts
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
11
 setupExternalResponse
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
14.40
 executeAction
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 setRequestExpectations
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
4.07
 logRequest
88.68% covered (warning)
88.68%
47 / 53
0.00% covered (danger)
0.00%
0 / 1
10.15
 encodeRequestLogValue
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getParamsUsed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markParamsUsed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSensitiveParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markParamsSensitive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getVal
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getCheck
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUpload
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 reportUnusedParams
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 printResult
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 isReadMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedParams
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 modifyHelp
100.00% covered (success)
100.00%
97 / 97
100.00% covered (success)
100.00%
1 / 1
12
 canApiHighLimits
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getModuleManager
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserAgent
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright Â© 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @defgroup API API
22 */
23
24use MediaWiki\Api\Validator\ApiParamValidator;
25use MediaWiki\Context\DerivativeContext;
26use MediaWiki\Context\IContextSource;
27use MediaWiki\Context\RequestContext;
28use MediaWiki\Html\Html;
29use MediaWiki\Logger\LoggerFactory;
30use MediaWiki\MainConfigNames;
31use MediaWiki\MediaWikiServices;
32use MediaWiki\ParamValidator\TypeDef\UserDef;
33use MediaWiki\Profiler\ProfilingContext;
34use MediaWiki\Request\FauxRequest;
35use MediaWiki\Request\WebRequest;
36use MediaWiki\Request\WebRequestUpload;
37use MediaWiki\Rest\HeaderParser\Origin;
38use MediaWiki\Session\SessionManager;
39use MediaWiki\StubObject\StubGlobalUser;
40use MediaWiki\User\UserRigorOptions;
41use MediaWiki\Utils\MWTimestamp;
42use MediaWiki\WikiMap\WikiMap;
43use Wikimedia\AtEase\AtEase;
44use Wikimedia\ParamValidator\ParamValidator;
45use Wikimedia\ParamValidator\TypeDef\IntegerDef;
46use Wikimedia\Stats\StatsFactory;
47use Wikimedia\Timestamp\TimestampException;
48
49/**
50 * This is the main API class, used for both external and internal processing.
51 * When executed, it will create the requested formatter object,
52 * instantiate and execute an object associated with the needed action,
53 * and use formatter to print results.
54 * In case of an exception, an error message will be printed using the same formatter.
55 *
56 * To use API from another application, run it using MediaWiki\Request\FauxRequest object, in which
57 * case any internal exceptions will not be handled but passed up to the caller.
58 * After successful execution, use getResult() for the resulting data.
59 *
60 * @newable
61 * @note marked as newable in 1.35 for lack of a better alternative,
62 *       but should use a factory in the future.
63 * @ingroup API
64 */
65class ApiMain extends ApiBase {
66    /**
67     * When no format parameter is given, this format will be used
68     */
69    private const API_DEFAULT_FORMAT = 'jsonfm';
70
71    /**
72     * When no uselang parameter is given, this language will be used
73     */
74    private const API_DEFAULT_USELANG = 'user';
75
76    /**
77     * List of available modules: action name => module class
78     */
79    private const MODULES = [
80        'login' => [
81            'class' => ApiLogin::class,
82            'services' => [
83                'AuthManager',
84            ],
85        ],
86        'clientlogin' => [
87            'class' => ApiClientLogin::class,
88            'services' => [
89                'AuthManager',
90            ],
91        ],
92        'logout' => [
93            'class' => ApiLogout::class,
94        ],
95        'createaccount' => [
96            'class' => ApiAMCreateAccount::class,
97            'services' => [
98                'AuthManager',
99            ],
100        ],
101        'linkaccount' => [
102            'class' => ApiLinkAccount::class,
103            'services' => [
104                'AuthManager',
105            ],
106        ],
107        'unlinkaccount' => [
108            'class' => ApiRemoveAuthenticationData::class,
109            'services' => [
110                'AuthManager',
111            ],
112        ],
113        'changeauthenticationdata' => [
114            'class' => ApiChangeAuthenticationData::class,
115            'services' => [
116                'AuthManager',
117            ],
118        ],
119        'removeauthenticationdata' => [
120            'class' => ApiRemoveAuthenticationData::class,
121            'services' => [
122                'AuthManager',
123            ],
124        ],
125        'resetpassword' => [
126            'class' => ApiResetPassword::class,
127            'services' => [
128                'PasswordReset',
129            ]
130        ],
131        'query' => [
132            'class' => ApiQuery::class,
133            'services' => [
134                'ObjectFactory',
135                'WikiExporterFactory',
136                'TitleFormatter',
137                'TitleFactory',
138            ]
139        ],
140        'expandtemplates' => [
141            'class' => ApiExpandTemplates::class,
142            'services' => [
143                'RevisionStore',
144                'ParserFactory',
145            ]
146        ],
147        'parse' => [
148            'class' => ApiParse::class,
149            'services' => [
150                'RevisionLookup',
151                'SkinFactory',
152                'LanguageNameUtils',
153                'LinkBatchFactory',
154                'LinkCache',
155                'ContentHandlerFactory',
156                'ParserFactory',
157                'WikiPageFactory',
158                'ContentRenderer',
159                'ContentTransformer',
160                'CommentFormatter',
161                'TempUserCreator',
162                'UserFactory',
163                'UrlUtils',
164                'TitleFormatter',
165            ]
166        ],
167        'stashedit' => [
168            'class' => ApiStashEdit::class,
169            'services' => [
170                'ContentHandlerFactory',
171                'PageEditStash',
172                'RevisionLookup',
173                'StatsdDataFactory',
174                'WikiPageFactory',
175                'TempUserCreator',
176                'UserFactory',
177            ]
178        ],
179        'opensearch' => [
180            'class' => ApiOpenSearch::class,
181            'services' => [
182                'LinkBatchFactory',
183                'SearchEngineConfig',
184                'SearchEngineFactory',
185                'UrlUtils',
186            ]
187        ],
188        'feedcontributions' => [
189            'class' => ApiFeedContributions::class,
190            'services' => [
191                'RevisionStore',
192                'TitleParser',
193                'LinkRenderer',
194                'LinkBatchFactory',
195                'HookContainer',
196                'DBLoadBalancerFactory',
197                'NamespaceInfo',
198                'UserFactory',
199                'CommentFormatter',
200            ]
201        ],
202        'feedrecentchanges' => [
203            'class' => ApiFeedRecentChanges::class,
204            'services' => [
205                'SpecialPageFactory',
206                'TempUserConfig',
207            ]
208        ],
209        'feedwatchlist' => [
210            'class' => ApiFeedWatchlist::class,
211            'services' => [
212                'ParserFactory',
213            ]
214        ],
215        'help' => [
216            'class' => ApiHelp::class,
217            'services' => [
218                'SkinFactory',
219            ]
220        ],
221        'paraminfo' => [
222            'class' => ApiParamInfo::class,
223            'services' => [
224                'UserFactory',
225            ],
226        ],
227        'rsd' => [
228            'class' => ApiRsd::class,
229        ],
230        'compare' => [
231            'class' => ApiComparePages::class,
232            'services' => [
233                'RevisionStore',
234                'ArchivedRevisionLookup',
235                'SlotRoleRegistry',
236                'ContentHandlerFactory',
237                'ContentTransformer',
238                'CommentFormatter',
239                'TempUserCreator',
240                'UserFactory',
241            ]
242        ],
243        'checktoken' => [
244            'class' => ApiCheckToken::class,
245        ],
246        'cspreport' => [
247            'class' => ApiCSPReport::class,
248        ],
249        'validatepassword' => [
250            'class' => ApiValidatePassword::class,
251            'services' => [
252                'AuthManager',
253                'UserFactory',
254            ]
255        ],
256
257        // Write modules
258        'purge' => [
259            'class' => ApiPurge::class,
260            'services' => [
261                'WikiPageFactory',
262                'TitleFormatter',
263            ],
264        ],
265        'setnotificationtimestamp' => [
266            'class' => ApiSetNotificationTimestamp::class,
267            'services' => [
268                'DBLoadBalancerFactory',
269                'RevisionStore',
270                'WatchedItemStore',
271                'TitleFormatter',
272                'TitleFactory',
273            ]
274        ],
275        'rollback' => [
276            'class' => ApiRollback::class,
277            'services' => [
278                'RollbackPageFactory',
279                'WatchlistManager',
280                'UserOptionsLookup',
281            ]
282        ],
283        'delete' => [
284            'class' => ApiDelete::class,
285            'services' => [
286                'RepoGroup',
287                'WatchlistManager',
288                'UserOptionsLookup',
289                'DeletePageFactory',
290            ]
291        ],
292        'undelete' => [
293            'class' => ApiUndelete::class,
294            'services' => [
295                'WatchlistManager',
296                'UserOptionsLookup',
297                'UndeletePageFactory',
298                'WikiPageFactory',
299            ]
300        ],
301        'protect' => [
302            'class' => ApiProtect::class,
303            'services' => [
304                'WatchlistManager',
305                'UserOptionsLookup',
306                'RestrictionStore',
307            ]
308        ],
309        'block' => [
310            'class' => ApiBlock::class,
311            'services' => [
312                'BlockPermissionCheckerFactory',
313                'BlockUserFactory',
314                'TitleFactory',
315                'UserIdentityLookup',
316                'WatchedItemStore',
317                'BlockUtils',
318                'BlockActionInfo',
319                'WatchlistManager',
320                'UserOptionsLookup',
321            ]
322        ],
323        'unblock' => [
324            'class' => ApiUnblock::class,
325            'services' => [
326                'BlockPermissionCheckerFactory',
327                'UnblockUserFactory',
328                'UserIdentityLookup',
329                'WatchedItemStore',
330                'WatchlistManager',
331                'UserOptionsLookup',
332            ]
333        ],
334        'move' => [
335            'class' => ApiMove::class,
336            'services' => [
337                'MovePageFactory',
338                'RepoGroup',
339                'WatchlistManager',
340                'UserOptionsLookup',
341            ]
342        ],
343        'edit' => [
344            'class' => ApiEditPage::class,
345            'services' => [
346                'ContentHandlerFactory',
347                'RevisionLookup',
348                'WatchedItemStore',
349                'WikiPageFactory',
350                'WatchlistManager',
351                'UserOptionsLookup',
352                'RedirectLookup',
353                'TempUserCreator',
354                'UserFactory',
355            ]
356        ],
357        'upload' => [
358            'class' => ApiUpload::class,
359            'services' => [
360                'JobQueueGroup',
361                'WatchlistManager',
362                'UserOptionsLookup',
363            ]
364        ],
365        'filerevert' => [
366            'class' => ApiFileRevert::class,
367            'services' => [
368                'RepoGroup',
369            ]
370        ],
371        'emailuser' => [
372            'class' => ApiEmailUser::class,
373        ],
374        'watch' => [
375            'class' => ApiWatch::class,
376            'services' => [
377                'WatchlistManager',
378                'TitleFormatter',
379            ]
380        ],
381        'patrol' => [
382            'class' => ApiPatrol::class,
383            'services' => [
384                'RevisionStore',
385            ]
386        ],
387        'import' => [
388            'class' => ApiImport::class,
389            'services' => [
390                'WikiImporterFactory',
391            ]
392        ],
393        'clearhasmsg' => [
394            'class' => ApiClearHasMsg::class,
395            'services' => [
396                'TalkPageNotificationManager',
397            ]
398        ],
399        'userrights' => [
400            'class' => ApiUserrights::class,
401            'services' => [
402                'UserGroupManager',
403                'WatchedItemStore',
404                'WatchlistManager',
405                'UserOptionsLookup',
406            ]
407        ],
408        'options' => [
409            'class' => ApiOptions::class,
410            'services' => [
411                'UserOptionsManager',
412                'PreferencesFactory',
413            ],
414        ],
415        'imagerotate' => [
416            'class' => ApiImageRotate::class,
417            'services' => [
418                'RepoGroup',
419                'TempFSFileFactory',
420                'TitleFactory',
421            ]
422        ],
423        'revisiondelete' => [
424            'class' => ApiRevisionDelete::class,
425        ],
426        'managetags' => [
427            'class' => ApiManageTags::class,
428        ],
429        'tag' => [
430            'class' => ApiTag::class,
431            'services' => [
432                'DBLoadBalancerFactory',
433                'RevisionStore',
434                'ChangeTagsStore',
435            ]
436        ],
437        'mergehistory' => [
438            'class' => ApiMergeHistory::class,
439            'services' => [
440                'MergeHistoryFactory',
441            ],
442        ],
443        'setpagelanguage' => [
444            'class' => ApiSetPageLanguage::class,
445            'services' => [
446                'DBLoadBalancerFactory',
447                'LanguageNameUtils',
448            ]
449        ],
450        'changecontentmodel' => [
451            'class' => ApiChangeContentModel::class,
452            'services' => [
453                'ContentHandlerFactory',
454                'ContentModelChangeFactory',
455            ]
456        ],
457        'acquiretempusername' => [
458            'class' => ApiAcquireTempUserName::class,
459            'services' => [
460                'TempUserCreator',
461            ]
462        ],
463    ];
464
465    /**
466     * List of available formats: format name => format class
467     */
468    private const FORMATS = [
469        'json' => [
470            'class' => ApiFormatJson::class,
471        ],
472        'jsonfm' => [
473            'class' => ApiFormatJson::class,
474        ],
475        'php' => [
476            'class' => ApiFormatPhp::class,
477        ],
478        'phpfm' => [
479            'class' => ApiFormatPhp::class,
480        ],
481        'xml' => [
482            'class' => ApiFormatXml::class,
483        ],
484        'xmlfm' => [
485            'class' => ApiFormatXml::class,
486        ],
487        'rawfm' => [
488            'class' => ApiFormatJson::class,
489        ],
490        'none' => [
491            'class' => ApiFormatNone::class,
492        ],
493    ];
494
495    /**
496     * List of user roles that are specifically relevant to the API.
497     * [ 'right' => [ 'msg'    => 'Some message with a $1',
498     *                'params' => [ $someVarToSubst ] ],
499     * ];
500     */
501    private const RIGHTS_MAP = [
502        'writeapi' => [
503            'msg' => 'right-writeapi',
504            'params' => []
505        ],
506        'apihighlimits' => [
507            'msg' => 'api-help-right-apihighlimits',
508            'params' => [ ApiBase::LIMIT_SML2, ApiBase::LIMIT_BIG2 ]
509        ]
510    ];
511
512    /** @var ApiFormatBase|null */
513    private $mPrinter;
514
515    /** @var ApiModuleManager */
516    private $mModuleMgr;
517
518    /** @var ApiResult */
519    private $mResult;
520
521    /** @var ApiErrorFormatter */
522    private $mErrorFormatter;
523
524    /** @var ApiParamValidator */
525    private $mParamValidator;
526
527    /** @var ApiContinuationManager|null */
528    private $mContinuationManager;
529
530    /** @var string|null */
531    private $mAction;
532
533    /** @var bool */
534    private $mEnableWrite;
535
536    /** @var bool */
537    private $mInternalMode;
538
539    /** @var ApiBase */
540    private $mModule;
541
542    /** @var string */
543    private $mCacheMode = 'private';
544
545    /** @var array */
546    private $mCacheControl = [];
547
548    /** @var array */
549    private $mParamsUsed = [];
550
551    /** @var array */
552    private $mParamsSensitive = [];
553
554    /** @var bool|null Cached return value from self::lacksSameOriginSecurity() */
555    private $lacksSameOriginSecurity = null;
556
557    /** @var StatsFactory */
558    private $statsFactory;
559
560    /**
561     * Constructs an instance of ApiMain that utilizes the module and format specified by $request.
562     *
563     * @stable to call
564     * @param IContextSource|WebRequest|null $context If this is an instance of
565     *    MediaWiki\Request\FauxRequest, errors are thrown and no printing occurs
566     * @param bool $enableWrite Should be set to true if the api may modify data
567     * @param bool|null $internal Whether the API request is an internal faux
568     *        request. If null or not given, the request is assumed to be internal
569     *        if $context contains a FauxRequest.
570     */
571    public function __construct( $context = null, $enableWrite = false, $internal = null ) {
572        if ( $context === null ) {
573            $context = RequestContext::getMain();
574        } elseif ( $context instanceof WebRequest ) {
575            // BC for pre-1.19
576            $request = $context;
577            $context = RequestContext::getMain();
578        }
579        // We set a derivative context so we can change stuff later
580        $derivativeContext = new DerivativeContext( $context );
581        $this->setContext( $derivativeContext );
582
583        if ( isset( $request ) ) {
584            $derivativeContext->setRequest( $request );
585        } else {
586            $request = $this->getRequest();
587        }
588
589        $this->mInternalMode = $internal ?? ( $request instanceof FauxRequest );
590
591        // Special handling for the main module: $parent === $this
592        parent::__construct( $this, $this->mInternalMode ? 'main_int' : 'main' );
593
594        $config = $this->getConfig();
595        // TODO inject stuff, see T265644
596        $services = MediaWikiServices::getInstance();
597
598        if ( !$this->mInternalMode ) {
599            // If we're in a mode that breaks the same-origin policy, strip
600            // user credentials for security.
601            if ( $this->lacksSameOriginSecurity() ) {
602                wfDebug( "API: stripping user credentials when the same-origin policy is not applied" );
603                $user = $services->getUserFactory()->newAnonymous();
604                StubGlobalUser::setUser( $user );
605                $derivativeContext->setUser( $user );
606                $request->response()->header( 'MediaWiki-Login-Suppressed: true' );
607            }
608        }
609
610        $this->mParamValidator = new ApiParamValidator(
611            $this,
612            $services->getObjectFactory()
613        );
614
615        $this->statsFactory = $services->getStatsFactory();
616
617        $this->mResult =
618            new ApiResult( $this->getConfig()->get( MainConfigNames::APIMaxResultSize ) );
619
620        // Setup uselang. This doesn't use $this->getParameter()
621        // because we're not ready to handle errors yet.
622        // Optimisation: Avoid slow getVal(), this isn't user-generated content.
623        $uselang = $request->getRawVal( 'uselang', self::API_DEFAULT_USELANG );
624        if ( $uselang === 'user' ) {
625            // Assume the parent context is going to return the user language
626            // for uselang=user (see T85635).
627        } else {
628            if ( $uselang === 'content' ) {
629                $uselang = $services->getContentLanguage()->getCode();
630            }
631            $code = RequestContext::sanitizeLangCode( $uselang );
632            $derivativeContext->setLanguage( $code );
633            if ( !$this->mInternalMode ) {
634                // phpcs:disable MediaWiki.Usage.ExtendClassUsage.FunctionVarUsage
635                global $wgLang;
636                $wgLang = $derivativeContext->getLanguage();
637                RequestContext::getMain()->setLanguage( $wgLang );
638                // phpcs:enable MediaWiki.Usage.ExtendClassUsage.FunctionVarUsage
639            }
640        }
641
642        // Set up the error formatter. This doesn't use $this->getParameter()
643        // because we're not ready to handle errors yet.
644        // Optimisation: Avoid slow getVal(), this isn't user-generated content.
645        $errorFormat = $request->getRawVal( 'errorformat', 'bc' );
646        $errorLangCode = $request->getRawVal( 'errorlang', 'uselang' );
647        $errorsUseDB = $request->getCheck( 'errorsuselocal' );
648        if ( in_array( $errorFormat, [ 'plaintext', 'wikitext', 'html', 'raw', 'none' ], true ) ) {
649            if ( $errorLangCode === 'uselang' ) {
650                $errorLang = $this->getLanguage();
651            } elseif ( $errorLangCode === 'content' ) {
652                $errorLang = $services->getContentLanguage();
653            } else {
654                $errorLangCode = RequestContext::sanitizeLangCode( $errorLangCode );
655                $errorLang = $services->getLanguageFactory()->getLanguage( $errorLangCode );
656            }
657            $this->mErrorFormatter = new ApiErrorFormatter(
658                $this->mResult,
659                $errorLang,
660                $errorFormat,
661                $errorsUseDB
662            );
663        } else {
664            $this->mErrorFormatter = new ApiErrorFormatter_BackCompat( $this->mResult );
665        }
666        $this->mResult->setErrorFormatter( $this->getErrorFormatter() );
667
668        $this->mModuleMgr = new ApiModuleManager(
669            $this,
670            $services->getObjectFactory()
671        );
672        $this->mModuleMgr->addModules( self::MODULES, 'action' );
673        $this->mModuleMgr->addModules( $config->get( MainConfigNames::APIModules ), 'action' );
674        $this->mModuleMgr->addModules( self::FORMATS, 'format' );
675        $this->mModuleMgr->addModules( $config->get( MainConfigNames::APIFormatModules ), 'format' );
676
677        $this->getHookRunner()->onApiMain__moduleManager( $this->mModuleMgr );
678
679        $this->mContinuationManager = null;
680        $this->mEnableWrite = $enableWrite;
681    }
682
683    /**
684     * Return true if the API was started by other PHP code using MediaWiki\Request\FauxRequest
685     * @return bool
686     */
687    public function isInternalMode() {
688        return $this->mInternalMode;
689    }
690
691    /**
692     * Get the ApiResult object associated with current request
693     *
694     * @return ApiResult
695     */
696    public function getResult() {
697        return $this->mResult;
698    }
699
700    /**
701     * Get the security flag for the current request
702     * @return bool
703     */
704    public function lacksSameOriginSecurity() {
705        if ( $this->lacksSameOriginSecurity !== null ) {
706            return $this->lacksSameOriginSecurity;
707        }
708
709        $request = $this->getRequest();
710
711        // JSONP mode
712        if ( $request->getCheck( 'callback' ) ||
713            // Anonymous CORS
714            $request->getVal( 'origin' ) === '*' ||
715            // Header to be used from XMLHTTPRequest when the request might
716            // otherwise be used for XSS.
717            $request->getHeader( 'Treat-as-Untrusted' ) !== false
718        ) {
719            $this->lacksSameOriginSecurity = true;
720            return true;
721        }
722
723        // Allow extensions to override.
724        $this->lacksSameOriginSecurity = !$this->getHookRunner()
725            ->onRequestHasSameOriginSecurity( $request );
726        return $this->lacksSameOriginSecurity;
727    }
728
729    /**
730     * Get the ApiErrorFormatter object associated with current request
731     * @return ApiErrorFormatter
732     */
733    public function getErrorFormatter() {
734        return $this->mErrorFormatter;
735    }
736
737    /**
738     * @return ApiContinuationManager|null
739     */
740    public function getContinuationManager() {
741        return $this->mContinuationManager;
742    }
743
744    /**
745     * @param ApiContinuationManager|null $manager
746     */
747    public function setContinuationManager( ApiContinuationManager $manager = null ) {
748        if ( $manager !== null && $this->mContinuationManager !== null ) {
749            throw new UnexpectedValueException(
750                __METHOD__ . ': tried to set manager from ' . $manager->getSource() .
751                ' when a manager is already set from ' . $this->mContinuationManager->getSource()
752            );
753        }
754        $this->mContinuationManager = $manager;
755    }
756
757    /**
758     * Get the parameter validator
759     * @return ApiParamValidator
760     */
761    public function getParamValidator(): ApiParamValidator {
762        return $this->mParamValidator;
763    }
764
765    /**
766     * Get the API module object. Only works after executeAction()
767     *
768     * @return ApiBase
769     */
770    public function getModule() {
771        return $this->mModule;
772    }
773
774    /**
775     * Get the result formatter object. Only works after setupExecuteAction()
776     *
777     * @return ApiFormatBase
778     */
779    public function getPrinter() {
780        return $this->mPrinter;
781    }
782
783    /**
784     * Set how long the response should be cached.
785     *
786     * @param int $maxage
787     */
788    public function setCacheMaxAge( $maxage ) {
789        $this->setCacheControl( [
790            'max-age' => $maxage,
791            's-maxage' => $maxage
792        ] );
793    }
794
795    /**
796     * Set the type of caching headers which will be sent.
797     *
798     * @param string $mode One of:
799     *    - 'public':     Cache this object in public caches, if the maxage or smaxage
800     *         parameter is set, or if setCacheMaxAge() was called. If a maximum age is
801     *         not provided by any of these means, the object will be private.
802     *    - 'private':    Cache this object only in private client-side caches.
803     *    - 'anon-public-user-private': Make this object cacheable for logged-out
804     *         users, but private for logged-in users. IMPORTANT: If this is set, it must be
805     *         set consistently for a given URL, it cannot be set differently depending on
806     *         things like the contents of the database, or whether the user is logged in.
807     *
808     *  If the wiki does not allow anonymous users to read it, the mode set here
809     *  will be ignored, and private caching headers will always be sent. In other words,
810     *  the "public" mode is equivalent to saying that the data sent is as public as a page
811     *  view.
812     *
813     *  For user-dependent data, the private mode should generally be used. The
814     *  anon-public-user-private mode should only be used where there is a particularly
815     *  good performance reason for caching the anonymous response, but where the
816     *  response to logged-in users may differ, or may contain private data.
817     *
818     *  If this function is never called, then the default will be the private mode.
819     */
820    public function setCacheMode( $mode ) {
821        if ( !in_array( $mode, [ 'private', 'public', 'anon-public-user-private' ] ) ) {
822            wfDebug( __METHOD__ . ": unrecognised cache mode \"$mode\"" );
823
824            // Ignore for forwards-compatibility
825            return;
826        }
827
828        if ( !$this->getPermissionManager()->isEveryoneAllowed( 'read' ) ) {
829            // Private wiki, only private headers
830            if ( $mode !== 'private' ) {
831                wfDebug( __METHOD__ . ": ignoring request for $mode cache mode, private wiki" );
832
833                return;
834            }
835        }
836
837        if ( $mode === 'public' && $this->getParameter( 'uselang' ) === 'user' ) {
838            // User language is used for i18n, so we don't want to publicly
839            // cache. Anons are ok, because if they have non-default language
840            // then there's an appropriate Vary header set by whatever set
841            // their non-default language.
842            wfDebug( __METHOD__ . ": downgrading cache mode 'public' to " .
843                "'anon-public-user-private' due to uselang=user" );
844            $mode = 'anon-public-user-private';
845        }
846
847        wfDebug( __METHOD__ . ": setting cache mode $mode" );
848        $this->mCacheMode = $mode;
849    }
850
851    public function getCacheMode() {
852        return $this->mCacheMode;
853    }
854
855    /**
856     * Set directives (key/value pairs) for the Cache-Control header.
857     * Boolean values will be formatted as such, by including or omitting
858     * without an equals sign.
859     *
860     * Cache control values set here will only be used if the cache mode is not
861     * private, see setCacheMode().
862     *
863     * @param array $directives
864     */
865    public function setCacheControl( $directives ) {
866        $this->mCacheControl = $directives + $this->mCacheControl;
867    }
868
869    /**
870     * Create an instance of an output formatter by its name
871     *
872     * @param string $format
873     *
874     * @return ApiFormatBase
875     */
876    public function createPrinterByName( $format ) {
877        $printer = $this->mModuleMgr->getModule( $format, 'format', /* $ignoreCache */ true );
878        if ( $printer === null ) {
879            $this->dieWithError(
880                [ 'apierror-unknownformat', wfEscapeWikiText( $format ) ], 'unknown_format'
881            );
882        }
883
884        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
885        return $printer;
886    }
887
888    /**
889     * Execute api request. Any errors will be handled if the API was called by the remote client.
890     */
891    public function execute() {
892        if ( $this->mInternalMode ) {
893            $this->executeAction();
894        } else {
895            $this->executeActionWithErrorHandling();
896        }
897    }
898
899    /**
900     * Execute an action, and in case of an error, erase whatever partial results
901     * have been accumulated, and replace it with an error message and a help screen.
902     */
903    protected function executeActionWithErrorHandling() {
904        // Verify the CORS header before executing the action
905        if ( !$this->handleCORS() ) {
906            // handleCORS() has sent a 403, abort
907            return;
908        }
909
910        // Exit here if the request method was OPTIONS
911        // (assume there will be a followup GET or POST)
912        if ( $this->getRequest()->getMethod() === 'OPTIONS' ) {
913            return;
914        }
915
916        // In case an error occurs during data output,
917        // clear the output buffer and print just the error information
918        $obLevel = ob_get_level();
919        ob_start();
920
921        $t = microtime( true );
922        $isError = false;
923        try {
924            $this->executeAction();
925            $runTime = microtime( true ) - $t;
926            $this->logRequest( $runTime );
927
928            $this->statsFactory->getTiming( 'api_executeTiming_seconds' )
929                ->setLabel( 'module', $this->mModule->getModuleName() )
930                ->copyToStatsdAt( 'api.' . $this->mModule->getModuleName() . '.executeTiming' )
931                ->observe( 1000 * $runTime );
932        } catch ( Throwable $e ) {
933            $this->handleException( $e );
934            $this->logRequest( microtime( true ) - $t, $e );
935            $isError = true;
936        }
937
938        // Disable the client cache on the output so that BlockManager::trackBlockWithCookie is executed
939        // as part of MediaWiki::preOutputCommit().
940        if (
941            $this->mCacheMode === 'private'
942            || (
943                $this->mCacheMode === 'anon-public-user-private'
944                && SessionManager::getGlobalSession()->isPersistent()
945            )
946        ) {
947            $this->getContext()->getOutput()->disableClientCache();
948            $this->getContext()->getOutput()->considerCacheSettingsFinal();
949        }
950
951        // Commit DBs and send any related cookies and headers
952        MediaWiki::preOutputCommit( $this->getContext() );
953
954        // Send cache headers after any code which might generate an error, to
955        // avoid sending public cache headers for errors.
956        $this->sendCacheHeaders( $isError );
957
958        // Executing the action might have already messed with the output
959        // buffers.
960        while ( ob_get_level() > $obLevel ) {
961            ob_end_flush();
962        }
963    }
964
965    /**
966     * Handle a throwable as an API response
967     *
968     * @since 1.23
969     * @param Throwable $e
970     */
971    protected function handleException( Throwable $e ) {
972        // T65145: Rollback any open database transactions
973        if ( !$e instanceof ApiUsageException ) {
974            // ApiUsageExceptions are intentional, so don't rollback if that's the case
975            MWExceptionHandler::rollbackPrimaryChangesAndLog(
976                $e,
977                MWExceptionHandler::CAUGHT_BY_ENTRYPOINT
978            );
979        }
980
981        // Allow extra cleanup and logging
982        $this->getHookRunner()->onApiMain__onException( $this, $e );
983
984        // Handle any kind of exception by outputting properly formatted error message.
985        // If this fails, an unhandled exception should be thrown so that global error
986        // handler will process and log it.
987
988        $errCodes = $this->substituteResultWithError( $e );
989
990        // Error results should not be cached
991        $this->setCacheMode( 'private' );
992
993        $response = $this->getRequest()->response();
994        $headerStr = 'MediaWiki-API-Error: ' . implode( ', ', $errCodes );
995        $response->header( $headerStr );
996
997        // Reset and print just the error message
998        ob_clean();
999
1000        // Printer may not be initialized if the extractRequestParams() fails for the main module
1001        $this->createErrorPrinter();
1002
1003        // Get desired HTTP code from an ApiUsageException. Don't use codes from other
1004        // exception types, as they are unlikely to be intended as an HTTP code.
1005        $httpCode = $e instanceof ApiUsageException ? $e->getCode() : 0;
1006
1007        $failed = false;
1008        try {
1009            $this->printResult( $httpCode );
1010        } catch ( ApiUsageException $ex ) {
1011            // The error printer itself is failing. Try suppressing its request
1012            // parameters and redo.
1013            $failed = true;
1014            $this->addWarning( 'apiwarn-errorprinterfailed' );
1015            foreach ( $ex->getStatusValue()->getErrors() as $error ) {
1016                try {
1017                    $this->mPrinter->addWarning( $error );
1018                } catch ( Throwable $ex2 ) {
1019                    // WTF?
1020                    $this->addWarning( $error );
1021                }
1022            }
1023        }
1024        if ( $failed ) {
1025            $this->mPrinter = null;
1026            $this->createErrorPrinter();
1027            // @phan-suppress-next-line PhanNonClassMethodCall False positive
1028            $this->mPrinter->forceDefaultParams();
1029            if ( $httpCode ) {
1030                $response->statusHeader( 200 ); // Reset in case the fallback doesn't want a non-200
1031            }
1032            $this->printResult( $httpCode );
1033        }
1034    }
1035
1036    /**
1037     * Handle a throwable from the ApiBeforeMain hook.
1038     *
1039     * This tries to print the throwable as an API response, to be more
1040     * friendly to clients. If it fails, it will rethrow the throwable.
1041     *
1042     * @since 1.23
1043     * @param Throwable $e
1044     * @throws Throwable
1045     */
1046    public static function handleApiBeforeMainException( Throwable $e ) {
1047        ob_start();
1048
1049        try {
1050            $main = new self( RequestContext::getMain(), false );
1051            $main->handleException( $e );
1052            $main->logRequest( 0, $e );
1053        } catch ( Throwable $e2 ) {
1054            // Nope, even that didn't work. Punt.
1055            throw $e;
1056        }
1057
1058        // Reset cache headers
1059        $main->sendCacheHeaders( true );
1060
1061        ob_end_flush();
1062    }
1063
1064    /**
1065     * Check the &origin= query parameter against the Origin: HTTP header and respond appropriately.
1066     *
1067     * If no origin parameter is present, nothing happens.
1068     * If an origin parameter is present but doesn't match the Origin header, a 403 status code
1069     * is set and false is returned.
1070     * If the parameter and the header do match, the header is checked against $wgCrossSiteAJAXdomains
1071     * and $wgCrossSiteAJAXdomainExceptions, and if the origin qualifies, the appropriate CORS
1072     * headers are set.
1073     * https://www.w3.org/TR/cors/#resource-requests
1074     * https://www.w3.org/TR/cors/#resource-preflight-requests
1075     *
1076     * @return bool False if the caller should abort (403 case), true otherwise (all other cases)
1077     */
1078    protected function handleCORS() {
1079        $originParam = $this->getParameter( 'origin' ); // defaults to null
1080        if ( $originParam === null ) {
1081            // No origin parameter, nothing to do
1082            return true;
1083        }
1084
1085        $request = $this->getRequest();
1086        $response = $request->response();
1087
1088        $allowTiming = false;
1089        $varyOrigin = true;
1090
1091        if ( $originParam === '*' ) {
1092            // Request for anonymous CORS
1093            // Technically we should check for the presence of an Origin header
1094            // and not process it as CORS if it's not set, but that would
1095            // require us to vary on Origin for all 'origin=*' requests which
1096            // we don't want to do.
1097            $matchedOrigin = true;
1098            $allowOrigin = '*';
1099            $allowCredentials = 'false';
1100            $varyOrigin = false; // No need to vary
1101        } else {
1102            // Non-anonymous CORS, check we allow the domain
1103
1104            // Origin: header is a space-separated list of origins, check all of them
1105            $originHeader = $request->getHeader( 'Origin' );
1106            if ( $originHeader === false ) {
1107                $origins = [];
1108            } else {
1109                $originHeader = trim( $originHeader );
1110                $origins = preg_split( '/\s+/', $originHeader );
1111            }
1112
1113            if ( !in_array( $originParam, $origins ) ) {
1114                // origin parameter set but incorrect
1115                // Send a 403 response
1116                $response->statusHeader( 403 );
1117                $response->header( 'Cache-Control: no-cache' );
1118                echo "'origin' parameter does not match Origin header\n";
1119
1120                return false;
1121            }
1122
1123            $config = $this->getConfig();
1124            $origin = Origin::parseHeaderList( $origins );
1125            $matchedOrigin = $origin->match(
1126                $config->get( MainConfigNames::CrossSiteAJAXdomains ),
1127                $config->get( MainConfigNames::CrossSiteAJAXdomainExceptions )
1128            );
1129
1130            $allowOrigin = $originHeader;
1131            $allowCredentials = 'true';
1132            $allowTiming = $originHeader;
1133        }
1134
1135        if ( $matchedOrigin ) {
1136            $requestedMethod = $request->getHeader( 'Access-Control-Request-Method' );
1137            $preflight = $request->getMethod() === 'OPTIONS' && $requestedMethod !== false;
1138            if ( $preflight ) {
1139                // We allow the actual request to send the following headers
1140                $requestedHeaders = $request->getHeader( 'Access-Control-Request-Headers' );
1141                $allowedHeaders = $this->getConfig()->get( MainConfigNames::AllowedCorsHeaders );
1142                if ( $requestedHeaders !== false ) {
1143                    if ( !self::matchRequestedHeaders( $requestedHeaders, $allowedHeaders ) ) {
1144                        $response->header( 'MediaWiki-CORS-Rejection: Unsupported header requested in preflight' );
1145                        return true;
1146                    }
1147                    $response->header( 'Access-Control-Allow-Headers: ' . $requestedHeaders );
1148                }
1149
1150                // We only allow the actual request to be GET, POST, or HEAD
1151                $response->header( 'Access-Control-Allow-Methods: POST, GET, HEAD' );
1152            }
1153
1154            $response->header( "Access-Control-Allow-Origin: $allowOrigin" );
1155            $response->header( "Access-Control-Allow-Credentials: $allowCredentials" );
1156            // https://www.w3.org/TR/resource-timing/#timing-allow-origin
1157            if ( $allowTiming !== false ) {
1158                $response->header( "Timing-Allow-Origin: $allowTiming" );
1159            }
1160
1161            if ( !$preflight ) {
1162                $response->header(
1163                    'Access-Control-Expose-Headers: MediaWiki-API-Error, Retry-After, X-Database-Lag, '
1164                    . 'MediaWiki-Login-Suppressed'
1165                );
1166            }
1167        } else {
1168            $response->header( 'MediaWiki-CORS-Rejection: Origin mismatch' );
1169        }
1170
1171        if ( $varyOrigin ) {
1172            $this->getOutput()->addVaryHeader( 'Origin' );
1173        }
1174
1175        return true;
1176    }
1177
1178    /**
1179     * Attempt to validate the value of Access-Control-Request-Headers against a list
1180     * of headers that we allow the follow up request to send.
1181     *
1182     * @param string $requestedHeaders Comma separated list of HTTP headers
1183     * @param string[] $allowedHeaders List of allowed HTTP headers
1184     * @return bool True if all requested headers are in the list of allowed headers
1185     */
1186    protected static function matchRequestedHeaders( $requestedHeaders, $allowedHeaders ) {
1187        if ( trim( $requestedHeaders ) === '' ) {
1188            return true;
1189        }
1190        $requestedHeaders = explode( ',', $requestedHeaders );
1191        $allowedHeaders = array_change_key_case(
1192            array_fill_keys( $allowedHeaders, true ), CASE_LOWER );
1193        foreach ( $requestedHeaders as $rHeader ) {
1194            $rHeader = strtolower( trim( $rHeader ) );
1195            if ( !isset( $allowedHeaders[$rHeader] ) ) {
1196                LoggerFactory::getInstance( 'api-warning' )->warning(
1197                    'CORS preflight failed on requested header: {header}', [
1198                        'header' => $rHeader
1199                    ]
1200                );
1201                return false;
1202            }
1203        }
1204        return true;
1205    }
1206
1207    /**
1208     * Send caching headers
1209     * @param bool $isError Whether an error response is being output
1210     * @since 1.26 added $isError parameter
1211     */
1212    protected function sendCacheHeaders( $isError ) {
1213        $response = $this->getRequest()->response();
1214        $out = $this->getOutput();
1215
1216        $out->addVaryHeader( 'Treat-as-Untrusted' );
1217
1218        $config = $this->getConfig();
1219
1220        if ( $config->get( MainConfigNames::VaryOnXFP ) ) {
1221            $out->addVaryHeader( 'X-Forwarded-Proto' );
1222        }
1223
1224        if ( !$isError && $this->mModule &&
1225            ( $this->getRequest()->getMethod() === 'GET' || $this->getRequest()->getMethod() === 'HEAD' )
1226        ) {
1227            $etag = $this->mModule->getConditionalRequestData( 'etag' );
1228            if ( $etag !== null ) {
1229                $response->header( "ETag: $etag" );
1230            }
1231            $lastMod = $this->mModule->getConditionalRequestData( 'last-modified' );
1232            if ( $lastMod !== null ) {
1233                $response->header( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $lastMod ) );
1234            }
1235        }
1236
1237        // The logic should be:
1238        // $this->mCacheControl['max-age'] is set?
1239        //    Use it, the module knows better than our guess.
1240        // !$this->mModule || $this->mModule->isWriteMode(), and mCacheMode is private?
1241        //    Use 0 because we can guess caching is probably the wrong thing to do.
1242        // Use $this->getParameter( 'maxage' ), which already defaults to 0.
1243        $maxage = 0;
1244        if ( isset( $this->mCacheControl['max-age'] ) ) {
1245            $maxage = $this->mCacheControl['max-age'];
1246        } elseif ( ( $this->mModule && !$this->mModule->isWriteMode() ) ||
1247            $this->mCacheMode !== 'private'
1248        ) {
1249            $maxage = $this->getParameter( 'maxage' );
1250        }
1251        $privateCache = 'private, must-revalidate, max-age=' . $maxage;
1252
1253        if ( $this->mCacheMode == 'private' ) {
1254            $response->header( "Cache-Control: $privateCache" );
1255            return;
1256        }
1257
1258        if ( $this->mCacheMode == 'anon-public-user-private' ) {
1259            $out->addVaryHeader( 'Cookie' );
1260            $response->header( $out->getVaryHeader() );
1261            if ( SessionManager::getGlobalSession()->isPersistent() ) {
1262                // Logged in or otherwise has session (e.g. anonymous users who have edited)
1263                // Mark request private
1264                $response->header( "Cache-Control: $privateCache" );
1265
1266                return;
1267            } // else anonymous, send public headers below
1268        }
1269
1270        // Send public headers
1271        $response->header( $out->getVaryHeader() );
1272
1273        // If nobody called setCacheMaxAge(), use the (s)maxage parameters
1274        if ( !isset( $this->mCacheControl['s-maxage'] ) ) {
1275            $this->mCacheControl['s-maxage'] = $this->getParameter( 'smaxage' );
1276        }
1277        if ( !isset( $this->mCacheControl['max-age'] ) ) {
1278            $this->mCacheControl['max-age'] = $this->getParameter( 'maxage' );
1279        }
1280
1281        if ( !$this->mCacheControl['s-maxage'] && !$this->mCacheControl['max-age'] ) {
1282            // Public cache not requested
1283            // Sending a Vary header in this case is harmless, and protects us
1284            // against conditional calls of setCacheMaxAge().
1285            $response->header( "Cache-Control: $privateCache" );
1286
1287            return;
1288        }
1289
1290        $this->mCacheControl['public'] = true;
1291
1292        // Send an Expires header
1293        $maxAge = min( $this->mCacheControl['s-maxage'], $this->mCacheControl['max-age'] );
1294        $expiryUnixTime = ( $maxAge == 0 ? 1 : time() + $maxAge );
1295        $response->header( 'Expires: ' . wfTimestamp( TS_RFC2822, $expiryUnixTime ) );
1296
1297        // Construct the Cache-Control header
1298        $ccHeader = '';
1299        $separator = '';
1300        foreach ( $this->mCacheControl as $name => $value ) {
1301            if ( is_bool( $value ) ) {
1302                if ( $value ) {
1303                    $ccHeader .= $separator . $name;
1304                    $separator = ', ';
1305                }
1306            } else {
1307                $ccHeader .= $separator . "$name=$value";
1308                $separator = ', ';
1309            }
1310        }
1311
1312        $response->header( "Cache-Control: $ccHeader" );
1313    }
1314
1315    /**
1316     * Create the printer for error output
1317     */
1318    private function createErrorPrinter() {
1319        if ( !isset( $this->mPrinter ) ) {
1320            $value = $this->getRequest()->getVal( 'format', self::API_DEFAULT_FORMAT );
1321            if ( !$this->mModuleMgr->isDefined( $value, 'format' ) ) {
1322                $value = self::API_DEFAULT_FORMAT;
1323            }
1324            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable getVal does not return null here
1325            $this->mPrinter = $this->createPrinterByName( $value );
1326        }
1327
1328        // Printer may not be able to handle errors. This is particularly
1329        // likely if the module returns something for getCustomPrinter().
1330        if ( !$this->mPrinter->canPrintErrors() ) {
1331            $this->mPrinter = $this->createPrinterByName( self::API_DEFAULT_FORMAT );
1332        }
1333    }
1334
1335    /**
1336     * Create an error message for the given throwable.
1337     *
1338     * If an ApiUsageException, errors/warnings will be extracted from the
1339     * embedded StatusValue.
1340     *
1341     * Any other throwable will be returned with a generic code and wrapper
1342     * text around the throwable's (presumably English) message as a single
1343     * error (no warnings).
1344     *
1345     * @param Throwable $e
1346     * @param string $type 'error' or 'warning'
1347     * @return ApiMessage[]
1348     * @since 1.27
1349     */
1350    protected function errorMessagesFromException( Throwable $e, $type = 'error' ) {
1351        $messages = [];
1352        if ( $e instanceof ApiUsageException ) {
1353            foreach ( $e->getStatusValue()->getErrorsByType( $type ) as $error ) {
1354                $messages[] = ApiMessage::create( $error );
1355            }
1356        } elseif ( $type !== 'error' ) {
1357            // None of the rest have any messages for non-error types
1358        } else {
1359            // TODO: Avoid embedding arbitrary class names in the error code.
1360            $class = preg_replace( '#^Wikimedia\\\\Rdbms\\\\#', '', get_class( $e ) );
1361            $code = 'internal_api_error_' . $class;
1362            $data = [ 'errorclass' => get_class( $e ) ];
1363            if ( MWExceptionRenderer::shouldShowExceptionDetails() ) {
1364                if ( $e instanceof ILocalizedException ) {
1365                    $msg = $e->getMessageObject();
1366                } elseif ( $e instanceof MessageSpecifier ) {
1367                    $msg = Message::newFromSpecifier( $e );
1368                } else {
1369                    $msg = wfEscapeWikiText( $e->getMessage() );
1370                }
1371                $params = [ 'apierror-exceptioncaught', WebRequest::getRequestId(), $msg ];
1372            } else {
1373                $params = [ 'apierror-exceptioncaughttype', WebRequest::getRequestId(), get_class( $e ) ];
1374            }
1375
1376            $messages[] = ApiMessage::create( $params, $code, $data );
1377        }
1378        return $messages;
1379    }
1380
1381    /**
1382     * Replace the result data with the information about a throwable.
1383     * @param Throwable $e
1384     * @return string[] Error codes
1385     */
1386    protected function substituteResultWithError( Throwable $e ) {
1387        $result = $this->getResult();
1388        $formatter = $this->getErrorFormatter();
1389        $config = $this->getConfig();
1390        $errorCodes = [];
1391
1392        // Remember existing warnings and errors across the reset
1393        $errors = $result->getResultData( [ 'errors' ] );
1394        $warnings = $result->getResultData( [ 'warnings' ] );
1395        $result->reset();
1396        if ( $warnings !== null ) {
1397            $result->addValue( null, 'warnings', $warnings, ApiResult::NO_SIZE_CHECK );
1398        }
1399        if ( $errors !== null ) {
1400            $result->addValue( null, 'errors', $errors, ApiResult::NO_SIZE_CHECK );
1401
1402            // Collect the copied error codes for the return value
1403            foreach ( $errors as $error ) {
1404                if ( isset( $error['code'] ) ) {
1405                    $errorCodes[$error['code']] = true;
1406                }
1407            }
1408        }
1409
1410        // Add errors from the exception
1411        $modulePath = $e instanceof ApiUsageException ? $e->getModulePath() : null;
1412        foreach ( $this->errorMessagesFromException( $e, 'error' ) as $msg ) {
1413            if ( ApiErrorFormatter::isValidApiCode( $msg->getApiCode() ) ) {
1414                $errorCodes[$msg->getApiCode()] = true;
1415            } else {
1416                LoggerFactory::getInstance( 'api-warning' )->error( 'Invalid API error code "{code}"', [
1417                    'code' => $msg->getApiCode(),
1418                    'exception' => $e,
1419                ] );
1420                $errorCodes['<invalid-code>'] = true;
1421            }
1422            $formatter->addError( $modulePath, $msg );
1423        }
1424        foreach ( $this->errorMessagesFromException( $e, 'warning' ) as $msg ) {
1425            $formatter->addWarning( $modulePath, $msg );
1426        }
1427
1428        // Add additional data. Path depends on whether we're in BC mode or not.
1429        // Data depends on the type of exception.
1430        if ( $formatter instanceof ApiErrorFormatter_BackCompat ) {
1431            $path = [ 'error' ];
1432        } else {
1433            $path = null;
1434        }
1435        if ( $e instanceof ApiUsageException ) {
1436            $link = (string)MediaWikiServices::getInstance()->getUrlUtils()->expand( wfScript( 'api' ) );
1437            $result->addContentValue(
1438                $path,
1439                'docref',
1440                trim(
1441                    $this->msg( 'api-usage-docref', $link )->inLanguage( $formatter->getLanguage() )->text()
1442                    . ' '
1443                    . $this->msg( 'api-usage-mailinglist-ref' )->inLanguage( $formatter->getLanguage() )->text()
1444                )
1445            );
1446        } elseif ( $config->get( MainConfigNames::ShowExceptionDetails ) ) {
1447            $result->addContentValue(
1448                $path,
1449                'trace',
1450                $this->msg( 'api-exception-trace',
1451                    get_class( $e ),
1452                    $e->getFile(),
1453                    $e->getLine(),
1454                    MWExceptionHandler::getRedactedTraceAsString( $e )
1455                )->inLanguage( $formatter->getLanguage() )->text()
1456            );
1457        }
1458
1459        // Add the id and such
1460        $this->addRequestedFields( [ 'servedby' ] );
1461
1462        return array_keys( $errorCodes );
1463    }
1464
1465    /**
1466     * Add requested fields to the result
1467     * @param string[] $force Which fields to force even if not requested. Accepted values are:
1468     *  - servedby
1469     */
1470    protected function addRequestedFields( $force = [] ) {
1471        $result = $this->getResult();
1472
1473        $requestid = $this->getParameter( 'requestid' );
1474        if ( $requestid !== null ) {
1475            $result->addValue( null, 'requestid', $requestid, ApiResult::NO_SIZE_CHECK );
1476        }
1477
1478        if ( $this->getConfig()->get( MainConfigNames::ShowHostnames ) && (
1479            in_array( 'servedby', $force, true ) || $this->getParameter( 'servedby' )
1480        ) ) {
1481            $result->addValue( null, 'servedby', wfHostname(), ApiResult::NO_SIZE_CHECK );
1482        }
1483
1484        if ( $this->getParameter( 'curtimestamp' ) ) {
1485            $result->addValue( null, 'curtimestamp', wfTimestamp( TS_ISO_8601 ), ApiResult::NO_SIZE_CHECK );
1486        }
1487
1488        if ( $this->getParameter( 'responselanginfo' ) ) {
1489            $result->addValue(
1490                null,
1491                'uselang',
1492                $this->getLanguage()->getCode(),
1493                ApiResult::NO_SIZE_CHECK
1494            );
1495            $result->addValue(
1496                null,
1497                'errorlang',
1498                $this->getErrorFormatter()->getLanguage()->getCode(),
1499                ApiResult::NO_SIZE_CHECK
1500            );
1501        }
1502    }
1503
1504    /**
1505     * Set up for the execution.
1506     * @return array
1507     */
1508    protected function setupExecuteAction() {
1509        $this->addRequestedFields();
1510
1511        $params = $this->extractRequestParams();
1512        $this->mAction = $params['action'];
1513
1514        return $params;
1515    }
1516
1517    /**
1518     * Set up the module for response
1519     * @return ApiBase The module that will handle this action
1520     * @throws ApiUsageException
1521     */
1522    protected function setupModule() {
1523        // Instantiate the module requested by the user
1524        $module = $this->mModuleMgr->getModule( $this->mAction, 'action' );
1525        if ( $module === null ) {
1526            // Probably can't happen
1527            // @codeCoverageIgnoreStart
1528            $this->dieWithError(
1529                [ 'apierror-unknownaction', wfEscapeWikiText( $this->mAction ) ],
1530                'unknown_action'
1531            );
1532            // @codeCoverageIgnoreEnd
1533        }
1534        $moduleParams = $module->extractRequestParams();
1535
1536        // Check token, if necessary
1537        if ( $module->needsToken() === true ) {
1538            throw new LogicException(
1539                "Module '{$module->getModuleName()}' must be updated for the new token handling. " .
1540                'See documentation for ApiBase::needsToken for details.'
1541            );
1542        }
1543        if ( $module->needsToken() ) {
1544            if ( !$module->mustBePosted() ) {
1545                throw new LogicException(
1546                    "Module '{$module->getModuleName()}' must require POST to use tokens."
1547                );
1548            }
1549
1550            if ( !isset( $moduleParams['token'] ) ) {
1551                // Probably can't happen
1552                // @codeCoverageIgnoreStart
1553                $module->dieWithError( [ 'apierror-missingparam', 'token' ] );
1554                // @codeCoverageIgnoreEnd
1555            }
1556
1557            $module->requirePostedParameters( [ 'token' ] );
1558
1559            if ( !$module->validateToken( $moduleParams['token'], $moduleParams ) ) {
1560                $module->dieWithError( 'apierror-badtoken' );
1561            }
1562        }
1563
1564        // @phan-suppress-next-line PhanTypeMismatchReturnNullable T240141
1565        return $module;
1566    }
1567
1568    /**
1569     * @return array
1570     */
1571    private function getMaxLag() {
1572        $services = MediaWikiServices::getInstance();
1573        $dbLag = $services->getDBLoadBalancer()->getMaxLag();
1574        $lagInfo = [
1575            'host' => $dbLag[0],
1576            'lag' => $dbLag[1],
1577            'type' => 'db'
1578        ];
1579
1580        $jobQueueLagFactor =
1581            $this->getConfig()->get( MainConfigNames::JobQueueIncludeInMaxLagFactor );
1582        if ( $jobQueueLagFactor ) {
1583            // Turn total number of jobs into seconds by using the configured value
1584            $totalJobs = array_sum( $services->getJobQueueGroup()->getQueueSizes() );
1585            $jobQueueLag = $totalJobs / (float)$jobQueueLagFactor;
1586            if ( $jobQueueLag > $lagInfo['lag'] ) {
1587                $lagInfo = [
1588                    'host' => wfHostname(), // XXX: Is there a better value that could be used?
1589                    'lag' => $jobQueueLag,
1590                    'type' => 'jobqueue',
1591                    'jobs' => $totalJobs,
1592                ];
1593            }
1594        }
1595
1596        $this->getHookRunner()->onApiMaxLagInfo( $lagInfo );
1597
1598        return $lagInfo;
1599    }
1600
1601    /**
1602     * Check the max lag if necessary
1603     * @param ApiBase $module Api module being used
1604     * @param array $params Array an array containing the request parameters.
1605     * @return bool True on success, false should exit immediately
1606     */
1607    protected function checkMaxLag( $module, $params ) {
1608        if ( $module->shouldCheckMaxlag() && isset( $params['maxlag'] ) ) {
1609            $maxLag = $params['maxlag'];
1610            $lagInfo = $this->getMaxLag();
1611            if ( $lagInfo['lag'] > $maxLag ) {
1612                $response = $this->getRequest()->response();
1613
1614                $response->header( 'Retry-After: ' . max( (int)$maxLag, 5 ) );
1615                $response->header( 'X-Database-Lag: ' . (int)$lagInfo['lag'] );
1616
1617                if ( $this->getConfig()->get( MainConfigNames::ShowHostnames ) ) {
1618                    $this->dieWithError(
1619                        [ 'apierror-maxlag', $lagInfo['lag'], $lagInfo['host'] ],
1620                        'maxlag',
1621                        $lagInfo
1622                    );
1623                }
1624
1625                $this->dieWithError( [ 'apierror-maxlag-generic', $lagInfo['lag'] ], 'maxlag', $lagInfo );
1626            }
1627        }
1628
1629        return true;
1630    }
1631
1632    /**
1633     * Check selected RFC 7232 precondition headers
1634     *
1635     * RFC 7232 envisions a particular model where you send your request to "a
1636     * resource", and for write requests that you can read "the resource" by
1637     * changing the method to GET. When the API receives a GET request, it
1638     * works out even though "the resource" from RFC 7232's perspective might
1639     * be many resources from MediaWiki's perspective. But it totally fails for
1640     * a POST, since what HTTP sees as "the resource" is probably just
1641     * "/api.php" with all the interesting bits in the body.
1642     *
1643     * Therefore, we only support RFC 7232 precondition headers for GET (and
1644     * HEAD). That means we don't need to bother with If-Match and
1645     * If-Unmodified-Since since they only apply to modification requests.
1646     *
1647     * And since we don't support Range, If-Range is ignored too.
1648     *
1649     * @since 1.26
1650     * @param ApiBase $module Api module being used
1651     * @return bool True on success, false should exit immediately
1652     */
1653    protected function checkConditionalRequestHeaders( $module ) {
1654        if ( $this->mInternalMode ) {
1655            // No headers to check in internal mode
1656            return true;
1657        }
1658
1659        if ( $this->getRequest()->getMethod() !== 'GET' && $this->getRequest()->getMethod() !== 'HEAD' ) {
1660            // Don't check POSTs
1661            return true;
1662        }
1663
1664        $return304 = false;
1665
1666        $ifNoneMatch = array_diff(
1667            $this->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST ) ?: [],
1668            [ '' ]
1669        );
1670        if ( $ifNoneMatch ) {
1671            // @phan-suppress-next-line PhanImpossibleTypeComparison
1672            if ( $ifNoneMatch === [ '*' ] ) {
1673                // API responses always "exist"
1674                $etag = '*';
1675            } else {
1676                $etag = $module->getConditionalRequestData( 'etag' );
1677            }
1678        }
1679        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $etag is declared when $ifNoneMatch is true
1680        if ( $ifNoneMatch && $etag !== null ) {
1681            $test = str_starts_with( $etag, 'W/' ) ? substr( $etag, 2 ) : $etag;
1682            $match = array_map( static function ( $s ) {
1683                return str_starts_with( $s, 'W/' ) ? substr( $s, 2 ) : $s;
1684            }, $ifNoneMatch );
1685            $return304 = in_array( $test, $match, true );
1686        } else {
1687            $value = trim( $this->getRequest()->getHeader( 'If-Modified-Since' ) );
1688
1689            // Some old browsers sends sizes after the date, like this:
1690            //  Wed, 20 Aug 2003 06:51:19 GMT; length=5202
1691            // Ignore that.
1692            $i = strpos( $value, ';' );
1693            if ( $i !== false ) {
1694                $value = trim( substr( $value, 0, $i ) );
1695            }
1696
1697            if ( $value !== '' ) {
1698                try {
1699                    $ts = new MWTimestamp( $value );
1700                    if (
1701                        // RFC 7231 IMF-fixdate
1702                        $ts->getTimestamp( TS_RFC2822 ) === $value ||
1703                        // RFC 850
1704                        $ts->format( 'l, d-M-y H:i:s' ) . ' GMT' === $value ||
1705                        // asctime (with and without space-padded day)
1706                        $ts->format( 'D M j H:i:s Y' ) === $value ||
1707                        $ts->format( 'D M  j H:i:s Y' ) === $value
1708                    ) {
1709                        $config = $this->getConfig();
1710                        $lastMod = $module->getConditionalRequestData( 'last-modified' );
1711                        if ( $lastMod !== null ) {
1712                            // Mix in some MediaWiki modification times
1713                            $modifiedTimes = [
1714                                'page' => $lastMod,
1715                                'user' => $this->getUser()->getTouched(),
1716                                'epoch' => $config->get( MainConfigNames::CacheEpoch ),
1717                            ];
1718
1719                            if ( $config->get( MainConfigNames::UseCdn ) ) {
1720                                // T46570: the core page itself may not change, but resources might
1721                                $modifiedTimes['sepoch'] = wfTimestamp(
1722                                    TS_MW, time() - $config->get( MainConfigNames::CdnMaxAge )
1723                                );
1724                            }
1725                            $this->getHookRunner()->onOutputPageCheckLastModified( $modifiedTimes, $this->getOutput() );
1726                            $lastMod = max( $modifiedTimes );
1727                            $return304 = wfTimestamp( TS_MW, $lastMod ) <= $ts->getTimestamp( TS_MW );
1728                        }
1729                    }
1730                } catch ( TimestampException $e ) {
1731                    // Invalid timestamp, ignore it
1732                }
1733            }
1734        }
1735
1736        if ( $return304 ) {
1737            $this->getRequest()->response()->statusHeader( 304 );
1738
1739            // Avoid outputting the compressed representation of a zero-length body
1740            AtEase::suppressWarnings();
1741            ini_set( 'zlib.output_compression', 0 );
1742            AtEase::restoreWarnings();
1743            wfResetOutputBuffers( false );
1744
1745            return false;
1746        }
1747
1748        return true;
1749    }
1750
1751    /**
1752     * Check for sufficient permissions to execute
1753     * @param ApiBase $module An Api module
1754     */
1755    protected function checkExecutePermissions( $module ) {
1756        $user = $this->getUser();
1757        if ( $module->isReadMode() && !$this->getPermissionManager()->isEveryoneAllowed( 'read' ) &&
1758            !$this->getAuthority()->isAllowed( 'read' )
1759        ) {
1760            $this->dieWithError( 'apierror-readapidenied' );
1761        }
1762
1763        if ( $module->isWriteMode() ) {
1764            if ( !$this->mEnableWrite ) {
1765                $this->dieWithError( 'apierror-noapiwrite' );
1766            } elseif ( !$this->getAuthority()->isAllowed( 'writeapi' ) ) {
1767                $this->dieWithError( 'apierror-writeapidenied' );
1768            } elseif ( $this->getRequest()->getHeader( 'Promise-Non-Write-API-Action' ) ) {
1769                $this->dieWithError( 'apierror-promised-nonwrite-api' );
1770            }
1771
1772            $this->checkReadOnly( $module );
1773        }
1774
1775        // Allow extensions to stop execution for arbitrary reasons.
1776        // TODO: change hook to accept Authority
1777        $message = 'hookaborted';
1778        if ( !$this->getHookRunner()->onApiCheckCanExecute( $module, $user, $message ) ) {
1779            $this->dieWithError( $message );
1780        }
1781    }
1782
1783    /**
1784     * Check if the DB is read-only for this user
1785     * @param ApiBase $module An Api module
1786     */
1787    protected function checkReadOnly( $module ) {
1788        if ( MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) {
1789            $this->dieReadOnly();
1790        }
1791
1792        if ( $module->isWriteMode()
1793            && $this->getUser()->isBot()
1794            && MediaWikiServices::getInstance()->getDBLoadBalancer()->hasReplicaServers()
1795        ) {
1796            $this->checkBotReadOnly();
1797        }
1798    }
1799
1800    /**
1801     * Check whether we are readonly for bots
1802     */
1803    private function checkBotReadOnly() {
1804        // Figure out how many servers have passed the lag threshold
1805        $numLagged = 0;
1806        $lagLimit = $this->getConfig()->get( MainConfigNames::APIMaxLagThreshold );
1807        $laggedServers = [];
1808        $loadBalancer = MediaWikiServices::getInstance()->getDBLoadBalancer();
1809        foreach ( $loadBalancer->getLagTimes() as $serverIndex => $lag ) {
1810            if ( $lag > $lagLimit ) {
1811                ++$numLagged;
1812                $laggedServers[] = $loadBalancer->getServerName( $serverIndex ) . " ({$lag}s)";
1813            }
1814        }
1815
1816        // If a majority of replica DBs are too lagged then disallow writes
1817        $replicaCount = $loadBalancer->getServerCount() - 1;
1818        if ( $numLagged >= ceil( $replicaCount / 2 ) ) {
1819            $laggedServers = implode( ', ', $laggedServers );
1820            wfDebugLog(
1821                'api-readonly', // Deprecate this channel in favor of api-warning?
1822                "Api request failed as read only because the following DBs are lagged: $laggedServers"
1823            );
1824            LoggerFactory::getInstance( 'api-warning' )->warning(
1825                "Api request failed as read only because the following DBs are lagged: {laggeddbs}", [
1826                    'laggeddbs' => $laggedServers,
1827                ]
1828            );
1829
1830            $this->dieWithError(
1831                'readonly_lag',
1832                'readonly',
1833                [ 'readonlyreason' => "Waiting for $numLagged lagged database(s)" ]
1834            );
1835        }
1836    }
1837
1838    /**
1839     * Check asserts of the user's rights
1840     * @param array $params
1841     */
1842    protected function checkAsserts( $params ) {
1843        if ( isset( $params['assert'] ) ) {
1844            $user = $this->getUser();
1845            switch ( $params['assert'] ) {
1846                case 'anon':
1847                    if ( $user->isRegistered() ) {
1848                        $this->dieWithError( 'apierror-assertanonfailed' );
1849                    }
1850                    break;
1851                case 'user':
1852                    if ( !$user->isRegistered() ) {
1853                        $this->dieWithError( 'apierror-assertuserfailed' );
1854                    }
1855                    break;
1856                case 'bot':
1857                    if ( !$this->getAuthority()->isAllowed( 'bot' ) ) {
1858                        $this->dieWithError( 'apierror-assertbotfailed' );
1859                    }
1860                    break;
1861            }
1862        }
1863        if ( isset( $params['assertuser'] ) ) {
1864            // TODO inject stuff, see T265644
1865            $assertUser = MediaWikiServices::getInstance()->getUserFactory()
1866                ->newFromName( $params['assertuser'], UserRigorOptions::RIGOR_NONE );
1867            if ( !$assertUser || !$this->getUser()->equals( $assertUser ) ) {
1868                $this->dieWithError(
1869                    [ 'apierror-assertnameduserfailed', wfEscapeWikiText( $params['assertuser'] ) ]
1870                );
1871            }
1872        }
1873    }
1874
1875    /**
1876     * Check POST for external response and setup result printer
1877     * @param ApiBase $module An Api module
1878     * @param array $params An array with the request parameters
1879     */
1880    protected function setupExternalResponse( $module, $params ) {
1881        $validMethods = [ 'GET', 'HEAD', 'POST', 'OPTIONS' ];
1882        $request = $this->getRequest();
1883
1884        if ( !in_array( $request->getMethod(), $validMethods ) ) {
1885            $this->dieWithError( 'apierror-invalidmethod', null, null, 405 );
1886        }
1887
1888        if ( !$request->wasPosted() && $module->mustBePosted() ) {
1889            // Module requires POST. GET request might still be allowed
1890            // if $wgDebugApi is true, otherwise fail.
1891            $this->dieWithErrorOrDebug( [ 'apierror-mustbeposted', $this->mAction ] );
1892        }
1893
1894        if ( $request->wasPosted() && !$request->getHeader( 'Content-Type' ) ) {
1895            $this->addDeprecation(
1896                'apiwarn-deprecation-post-without-content-type', 'post-without-content-type'
1897            );
1898        }
1899
1900        // See if custom printer is used
1901        $this->mPrinter = $module->getCustomPrinter() ??
1902            // Create an appropriate printer if not set
1903            $this->createPrinterByName( $params['format'] );
1904
1905        if ( $request->getProtocol() === 'http' &&
1906            (
1907                $this->getConfig()->get( MainConfigNames::ForceHTTPS ) ||
1908                $request->getSession()->shouldForceHTTPS() ||
1909                $this->getUser()->requiresHTTPS()
1910            )
1911        ) {
1912            $this->addDeprecation( 'apiwarn-deprecation-httpsexpected', 'https-expected' );
1913        }
1914    }
1915
1916    /**
1917     * Execute the actual module, without any error handling
1918     */
1919    protected function executeAction() {
1920        $params = $this->setupExecuteAction();
1921
1922        // Check asserts early so e.g. errors in parsing a module's parameters due to being
1923        // logged out don't override the client's intended "am I logged in?" check.
1924        $this->checkAsserts( $params );
1925
1926        $module = $this->setupModule();
1927        $this->mModule = $module;
1928
1929        if ( !$this->mInternalMode ) {
1930            ProfilingContext::singleton()->init( MW_ENTRY_POINT, $module->getModuleName() );
1931            $this->setRequestExpectations( $module );
1932        }
1933
1934        $this->checkExecutePermissions( $module );
1935
1936        if ( !$this->checkMaxLag( $module, $params ) ) {
1937            return;
1938        }
1939
1940        if ( !$this->checkConditionalRequestHeaders( $module ) ) {
1941            return;
1942        }
1943
1944        if ( !$this->mInternalMode ) {
1945            $this->setupExternalResponse( $module, $params );
1946        }
1947
1948        $module->execute();
1949        $this->getHookRunner()->onAPIAfterExecute( $module );
1950
1951        $this->reportUnusedParams();
1952
1953        if ( !$this->mInternalMode ) {
1954            MWDebug::appendDebugInfoToApiResult( $this->getContext(), $this->getResult() );
1955
1956            $this->printResult();
1957        }
1958    }
1959
1960    /**
1961     * Set database connection, query, and write expectations given this module request
1962     * @param ApiBase $module
1963     */
1964    protected function setRequestExpectations( ApiBase $module ) {
1965        $request = $this->getRequest();
1966
1967        $trxLimits = $this->getConfig()->get( MainConfigNames::TrxProfilerLimits );
1968        $trxProfiler = Profiler::instance()->getTransactionProfiler();
1969        $trxProfiler->setLogger( LoggerFactory::getInstance( 'rdbms' ) );
1970        $statsFactory = MediaWikiServices::getInstance()->getStatsdDataFactory();
1971        $trxProfiler->setStatsdDataFactory( $statsFactory );
1972        $trxProfiler->setRequestMethod( $request->getMethod() );
1973        if ( $request->hasSafeMethod() ) {
1974            $trxProfiler->setExpectations( $trxLimits['GET'], __METHOD__ );
1975        } elseif ( $request->wasPosted() && !$module->isWriteMode() ) {
1976            $trxProfiler->setExpectations( $trxLimits['POST-nonwrite'], __METHOD__ );
1977        } else {
1978            $trxProfiler->setExpectations( $trxLimits['POST'], __METHOD__ );
1979        }
1980    }
1981
1982    /**
1983     * Log the preceding request
1984     * @param float $time Time in seconds
1985     * @param Throwable|null $e Throwable caught while processing the request
1986     */
1987    protected function logRequest( $time, Throwable $e = null ) {
1988        $request = $this->getRequest();
1989
1990        $user = $this->getUser();
1991        $performer = [
1992            'user_text' => $user->getName(),
1993        ];
1994        if ( $user->isRegistered() ) {
1995            $performer['user_id'] = $user->getId();
1996        }
1997        $logCtx = [
1998            // https://gerrit.wikimedia.org/g/mediawiki/event-schemas/+/master/jsonschema/mediawiki/api/request
1999            '$schema' => '/mediawiki/api/request/1.0.0',
2000            'meta' => [
2001                'request_id' => WebRequest::getRequestId(),
2002                'id' => MediaWikiServices::getInstance()
2003                    ->getGlobalIdGenerator()->newUUIDv4(),
2004                'dt' => wfTimestamp( TS_ISO_8601 ),
2005                'domain' => $this->getConfig()->get( MainConfigNames::ServerName ),
2006                // If using the EventBus extension (as intended) with this log channel,
2007                // this stream name will map to a Kafka topic.
2008                'stream' => 'mediawiki.api-request'
2009            ],
2010            'http' => [
2011                'method' => $request->getMethod(),
2012                'client_ip' => $request->getIP()
2013            ],
2014            'performer' => $performer,
2015            'database' => WikiMap::getCurrentWikiDbDomain()->getId(),
2016            'backend_time_ms' => (int)round( $time * 1000 ),
2017        ];
2018
2019        // If set, these headers will be logged in http.request_headers.
2020        $httpRequestHeadersToLog = [ 'accept-language', 'referer', 'user-agent' ];
2021        foreach ( $httpRequestHeadersToLog as $header ) {
2022            if ( $request->getHeader( $header ) ) {
2023                // Set the header in http.request_headers
2024                $logCtx['http']['request_headers'][$header] = $request->getHeader( $header );
2025            }
2026        }
2027
2028        if ( $e ) {
2029            $logCtx['api_error_codes'] = [];
2030            foreach ( $this->errorMessagesFromException( $e ) as $msg ) {
2031                $logCtx['api_error_codes'][] = $msg->getApiCode();
2032            }
2033        }
2034
2035        // Construct space separated message for 'api' log channel
2036        $msg = "API {$request->getMethod()} " .
2037            wfUrlencode( str_replace( ' ', '_', $this->getUser()->getName() ) ) .
2038            " {$logCtx['http']['client_ip']} " .
2039            "T={$logCtx['backend_time_ms']}ms";
2040
2041        $sensitive = array_fill_keys( $this->getSensitiveParams(), true );
2042        foreach ( $this->getParamsUsed() as $name ) {
2043            $value = $request->getVal( $name );
2044            if ( $value === null ) {
2045                continue;
2046            }
2047
2048            if ( isset( $sensitive[$name] ) ) {
2049                $value = '[redacted]';
2050                $encValue = '[redacted]';
2051            } elseif ( strlen( $value ) > 256 ) {
2052                $value = substr( $value, 0, 256 );
2053                $encValue = $this->encodeRequestLogValue( $value ) . '[...]';
2054            } else {
2055                $encValue = $this->encodeRequestLogValue( $value );
2056            }
2057
2058            $logCtx['params'][$name] = $value;
2059            $msg .= " {$name}={$encValue}";
2060        }
2061
2062        // Log an unstructured message to the api channel.
2063        wfDebugLog( 'api', $msg, 'private' );
2064
2065        // The api-request channel a structured data log channel.
2066        wfDebugLog( 'api-request', '', 'private', $logCtx );
2067    }
2068
2069    /**
2070     * Encode a value in a format suitable for a space-separated log line.
2071     * @param string $s
2072     * @return string
2073     */
2074    protected function encodeRequestLogValue( $s ) {
2075        static $table = [];
2076        if ( !$table ) {
2077            $chars = ';@$!*(),/:';
2078            $numChars = strlen( $chars );
2079            for ( $i = 0; $i < $numChars; $i++ ) {
2080                $table[rawurlencode( $chars[$i] )] = $chars[$i];
2081            }
2082        }
2083
2084        return strtr( rawurlencode( $s ), $table );
2085    }
2086
2087    /**
2088     * Get the request parameters used in the course of the preceding execute() request
2089     * @return array
2090     */
2091    protected function getParamsUsed() {
2092        return array_keys( $this->mParamsUsed );
2093    }
2094
2095    /**
2096     * Mark parameters as used
2097     * @param string|string[] $params
2098     */
2099    public function markParamsUsed( $params ) {
2100        $this->mParamsUsed += array_fill_keys( (array)$params, true );
2101    }
2102
2103    /**
2104     * Get the request parameters that should be considered sensitive
2105     * @since 1.29
2106     * @return array
2107     */
2108    protected function getSensitiveParams() {
2109        return array_keys( $this->mParamsSensitive );
2110    }
2111
2112    /**
2113     * Mark parameters as sensitive
2114     *
2115     * This is called automatically for you when declaring a parameter
2116     * with ApiBase::PARAM_SENSITIVE.
2117     *
2118     * @since 1.29
2119     * @param string|string[] $params
2120     */
2121    public function markParamsSensitive( $params ) {
2122        $this->mParamsSensitive += array_fill_keys( (array)$params, true );
2123    }
2124
2125    /**
2126     * Get a request value, and register the fact that it was used, for logging.
2127     * @param string $name
2128     * @param string|null $default
2129     * @return string|null
2130     */
2131    public function getVal( $name, $default = null ) {
2132        $this->mParamsUsed[$name] = true;
2133
2134        $ret = $this->getRequest()->getVal( $name );
2135        if ( $ret === null ) {
2136            if ( $this->getRequest()->getArray( $name ) !== null ) {
2137                // See T12262 for why we don't just implode( '|', ... ) the
2138                // array.
2139                $this->addWarning( [ 'apiwarn-unsupportedarray', $name ] );
2140            }
2141            $ret = $default;
2142        }
2143        return $ret;
2144    }
2145
2146    /**
2147     * Get a boolean request value, and register the fact that the parameter
2148     * was used, for logging.
2149     * @param string $name
2150     * @return bool
2151     */
2152    public function getCheck( $name ) {
2153        $this->mParamsUsed[$name] = true;
2154        return $this->getRequest()->getCheck( $name );
2155    }
2156
2157    /**
2158     * Get a request upload, and register the fact that it was used, for logging.
2159     *
2160     * @since 1.21
2161     * @param string $name Parameter name
2162     * @return WebRequestUpload
2163     */
2164    public function getUpload( $name ) {
2165        $this->mParamsUsed[$name] = true;
2166
2167        return $this->getRequest()->getUpload( $name );
2168    }
2169
2170    /**
2171     * Report unused parameters, so the client gets a hint in case it gave us parameters we don't know,
2172     * for example in case of spelling mistakes or a missing 'g' prefix for generators.
2173     */
2174    protected function reportUnusedParams() {
2175        $paramsUsed = $this->getParamsUsed();
2176        $allParams = $this->getRequest()->getValueNames();
2177
2178        if ( !$this->mInternalMode ) {
2179            // Printer has not yet executed; don't warn that its parameters are unused
2180            $printerParams = $this->mPrinter->encodeParamName(
2181                array_keys( $this->mPrinter->getFinalParams() ?: [] )
2182            );
2183            $unusedParams = array_diff( $allParams, $paramsUsed, $printerParams );
2184        } else {
2185            $unusedParams = array_diff( $allParams, $paramsUsed );
2186        }
2187
2188        if ( count( $unusedParams ) ) {
2189            $this->addWarning( [
2190                'apierror-unrecognizedparams',
2191                Message::listParam( array_map( 'wfEscapeWikiText', $unusedParams ), 'comma' ),
2192                count( $unusedParams )
2193            ] );
2194        }
2195    }
2196
2197    /**
2198     * Print results using the current printer
2199     *
2200     * @param int $httpCode HTTP status code, or 0 to not change
2201     */
2202    protected function printResult( $httpCode = 0 ) {
2203        if ( $this->getConfig()->get( MainConfigNames::DebugAPI ) !== false ) {
2204            $this->addWarning( 'apiwarn-wgdebugapi' );
2205        }
2206
2207        $printer = $this->mPrinter;
2208        $printer->initPrinter( false );
2209        if ( $httpCode ) {
2210            $printer->setHttpStatus( $httpCode );
2211        }
2212        $printer->execute();
2213        $printer->closePrinter();
2214    }
2215
2216    /**
2217     * @return bool
2218     */
2219    public function isReadMode() {
2220        return false;
2221    }
2222
2223    /**
2224     * See ApiBase for description.
2225     *
2226     * @return array
2227     */
2228    public function getAllowedParams() {
2229        return [
2230            'action' => [
2231                ParamValidator::PARAM_DEFAULT => 'help',
2232                ParamValidator::PARAM_TYPE => 'submodule',
2233            ],
2234            'format' => [
2235                ParamValidator::PARAM_DEFAULT => self::API_DEFAULT_FORMAT,
2236                ParamValidator::PARAM_TYPE => 'submodule',
2237            ],
2238            'maxlag' => [
2239                ParamValidator::PARAM_TYPE => 'integer'
2240            ],
2241            'smaxage' => [
2242                ParamValidator::PARAM_TYPE => 'integer',
2243                ParamValidator::PARAM_DEFAULT => 0,
2244                IntegerDef::PARAM_MIN => 0,
2245            ],
2246            'maxage' => [
2247                ParamValidator::PARAM_TYPE => 'integer',
2248                ParamValidator::PARAM_DEFAULT => 0,
2249                IntegerDef::PARAM_MIN => 0,
2250            ],
2251            'assert' => [
2252                ParamValidator::PARAM_TYPE => [ 'anon', 'user', 'bot' ]
2253            ],
2254            'assertuser' => [
2255                ParamValidator::PARAM_TYPE => 'user',
2256                UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'temp' ],
2257            ],
2258            'requestid' => null,
2259            'servedby' => false,
2260            'curtimestamp' => false,
2261            'responselanginfo' => false,
2262            'origin' => null,
2263            'uselang' => [
2264                ParamValidator::PARAM_DEFAULT => self::API_DEFAULT_USELANG,
2265            ],
2266            'variant' => null,
2267            'errorformat' => [
2268                ParamValidator::PARAM_TYPE => [ 'plaintext', 'wikitext', 'html', 'raw', 'none', 'bc' ],
2269                ParamValidator::PARAM_DEFAULT => 'bc',
2270                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
2271            ],
2272            'errorlang' => [
2273                ParamValidator::PARAM_DEFAULT => 'uselang',
2274            ],
2275            'errorsuselocal' => [
2276                ParamValidator::PARAM_DEFAULT => false,
2277            ],
2278        ];
2279    }
2280
2281    /** @inheritDoc */
2282    protected function getExamplesMessages() {
2283        return [
2284            'action=help'
2285                => 'apihelp-help-example-main',
2286            'action=help&recursivesubmodules=1'
2287                => 'apihelp-help-example-recursive',
2288        ];
2289    }
2290
2291    /**
2292     * @inheritDoc
2293     * @phan-param array{nolead?:bool,headerlevel?:int,tocnumber?:int[]} $options
2294     */
2295    public function modifyHelp( array &$help, array $options, array &$tocData ) {
2296        // Wish PHP had an "array_insert_before". Instead, we have to manually
2297        // reindex the array to get 'permissions' in the right place.
2298        $oldHelp = $help;
2299        $help = [];
2300        foreach ( $oldHelp as $k => $v ) {
2301            if ( $k === 'submodules' ) {
2302                $help['permissions'] = '';
2303            }
2304            $help[$k] = $v;
2305        }
2306        $help['datatypes'] = '';
2307        $help['templatedparams'] = '';
2308        $help['credits'] = '';
2309
2310        // Fill 'permissions'
2311        $help['permissions'] .= Html::openElement( 'div',
2312            [ 'class' => [ 'apihelp-block', 'apihelp-permissions' ] ] );
2313        $m = $this->msg( 'api-help-permissions' );
2314        if ( !$m->isDisabled() ) {
2315            $help['permissions'] .= Html::rawElement( 'div', [ 'class' => 'apihelp-block-head' ],
2316                $m->numParams( count( self::RIGHTS_MAP ) )->parse()
2317            );
2318        }
2319        $help['permissions'] .= Html::openElement( 'dl' );
2320        // TODO inject stuff, see T265644
2321        $groupPermissionsLookup = MediaWikiServices::getInstance()->getGroupPermissionsLookup();
2322        foreach ( self::RIGHTS_MAP as $right => $rightMsg ) {
2323            $help['permissions'] .= Html::element( 'dt', [], $right );
2324
2325            $rightMsg = $this->msg( $rightMsg['msg'], $rightMsg['params'] )->parse();
2326            $help['permissions'] .= Html::rawElement( 'dd', [], $rightMsg );
2327
2328            $groups = array_map( static function ( $group ) {
2329                return $group == '*' ? 'all' : $group;
2330            }, $groupPermissionsLookup->getGroupsWithPermission( $right ) );
2331
2332            $help['permissions'] .= Html::rawElement( 'dd', [],
2333                $this->msg( 'api-help-permissions-granted-to' )
2334                    ->numParams( count( $groups ) )
2335                    ->params( Message::listParam( $groups ) )
2336                    ->parse()
2337            );
2338        }
2339        $help['permissions'] .= Html::closeElement( 'dl' );
2340        $help['permissions'] .= Html::closeElement( 'div' );
2341
2342        // Fill 'datatypes', 'templatedparams', and 'credits', if applicable
2343        if ( empty( $options['nolead'] ) ) {
2344            // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Must set when nolead is not set
2345            $level = $options['headerlevel'];
2346            // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Must set when nolead is not set
2347            $tocnumber = &$options['tocnumber'];
2348
2349            $header = $this->msg( 'api-help-datatypes-header' )->parse();
2350            $headline = Html::rawElement(
2351                'h' . min( 6, $level ),
2352                [ 'class' => 'apihelp-header', 'id' => 'main/datatypes' ],
2353                $header
2354            );
2355            $help['datatypes'] .= $headline;
2356            $help['datatypes'] .= $this->msg( 'api-help-datatypes-top' )->parseAsBlock();
2357            $help['datatypes'] .= '<dl>';
2358            foreach ( $this->getParamValidator()->knownTypes() as $type ) {
2359                $m = $this->msg( "api-help-datatype-$type" );
2360                if ( !$m->isDisabled() ) {
2361                    $help['datatypes'] .= Html::element( 'dt', [ 'id' => "main/datatype/$type" ], $type );
2362                    $help['datatypes'] .= Html::rawElement( 'dd', [], $m->parseAsBlock() );
2363                }
2364            }
2365            $help['datatypes'] .= '</dl>';
2366            if ( !isset( $tocData['main/datatypes'] ) ) {
2367                $tocnumber[$level]++;
2368                $tocData['main/datatypes'] = [
2369                    'toclevel' => count( $tocnumber ),
2370                    'level' => $level,
2371                    'anchor' => 'main/datatypes',
2372                    'line' => $header,
2373                    'number' => implode( '.', $tocnumber ),
2374                    'index' => '',
2375                ];
2376            }
2377
2378            $header = $this->msg( 'api-help-templatedparams-header' )->parse();
2379            $headline = Html::rawElement(
2380                'h' . min( 6, $level ),
2381                [ 'class' => 'apihelp-header', 'id' => 'main/templatedparams' ],
2382                $header
2383            );
2384            $help['templatedparams'] .= $headline;
2385            $help['templatedparams'] .= $this->msg( 'api-help-templatedparams' )->parseAsBlock();
2386            if ( !isset( $tocData['main/templatedparams'] ) ) {
2387                $tocnumber[$level]++;
2388                $tocData['main/templatedparams'] = [
2389                    'toclevel' => count( $tocnumber ),
2390                    'level' => $level,
2391                    'anchor' => 'main/templatedparams',
2392                    'line' => $header,
2393                    'number' => implode( '.', $tocnumber ),
2394                    'index' => '',
2395                ];
2396            }
2397
2398            $header = $this->msg( 'api-credits-header' )->parse();
2399            $headline = Html::rawElement(
2400                'h' . min( 6, $level ),
2401                [ 'class' => 'apihelp-header', 'id' => 'main/credits' ],
2402                $header
2403            );
2404            $help['credits'] .= $headline;
2405            $help['credits'] .= $this->msg( 'api-credits' )->useDatabase( false )->parseAsBlock();
2406            if ( !isset( $tocData['main/credits'] ) ) {
2407                $tocnumber[$level]++;
2408                $tocData['main/credits'] = [
2409                    'toclevel' => count( $tocnumber ),
2410                    'level' => $level,
2411                    'anchor' => 'main/credits',
2412                    'line' => $header,
2413                    'number' => implode( '.', $tocnumber ),
2414                    'index' => '',
2415                ];
2416            }
2417        }
2418    }
2419
2420    private $mCanApiHighLimits = null;
2421
2422    /**
2423     * Check whether the current user is allowed to use high limits
2424     * @return bool
2425     */
2426    public function canApiHighLimits() {
2427        if ( !isset( $this->mCanApiHighLimits ) ) {
2428            $this->mCanApiHighLimits = $this->getAuthority()->isAllowed( 'apihighlimits' );
2429        }
2430
2431        return $this->mCanApiHighLimits;
2432    }
2433
2434    /**
2435     * Overrides to return this instance's module manager.
2436     * @return ApiModuleManager
2437     */
2438    public function getModuleManager() {
2439        return $this->mModuleMgr;
2440    }
2441
2442    /**
2443     * Fetches the user agent used for this request
2444     *
2445     * The value will be the combination of the 'Api-User-Agent' header (if
2446     * any) and the standard User-Agent header (if any).
2447     *
2448     * @return string
2449     */
2450    public function getUserAgent() {
2451        return trim(
2452            $this->getRequest()->getHeader( 'Api-user-agent' ) . ' ' .
2453            $this->getRequest()->getHeader( 'User-agent' )
2454        );
2455    }
2456}
2457
2458/**
2459 * For really cool vim folding this needs to be at the end:
2460 * vim: foldmarker=@{,@} foldmethod=marker
2461 */