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