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