Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.46% covered (danger)
44.46%
273 / 614
17.65% covered (danger)
17.65%
3 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryInfo
44.54% covered (danger)
44.54%
273 / 613
17.65% covered (danger)
17.65%
3 / 17
4041.53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 requestExtraData
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 execute
71.05% covered (warning)
71.05%
54 / 76
0.00% covered (danger)
0.00%
0 / 1
40.16
 extractPageInfo
45.86% covered (danger)
45.86%
72 / 157
0.00% covered (danger)
0.00%
0 / 1
553.66
 getProtectionInfo
0.00% covered (danger)
0.00%
0 / 105
0.00% covered (danger)
0.00%
0 / 1
272
 getTSIDs
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
72
 getDisplayTitle
33.33% covered (danger)
33.33%
4 / 12
0.00% covered (danger)
0.00%
0 / 1
5.67
 getLinkClasses
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
 getVariantTitles
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 getAllVariants
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getWatchedInfo
65.52% covered (warning)
65.52%
19 / 29
0.00% covered (danger)
0.00%
0 / 1
15.96
 getWatcherInfo
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 getVisitingWatcherInfo
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
56
 getCacheMode
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 getAllowedParams
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 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 */
8
9namespace MediaWiki\Api;
10
11use MediaWiki\Deferred\LinksUpdate\ImageLinksTable;
12use MediaWiki\Deferred\LinksUpdate\TemplateLinksTable;
13use MediaWiki\EditPage\IntroMessageBuilder;
14use MediaWiki\EditPage\PreloadedContentBuilder;
15use MediaWiki\Language\ILanguageConverter;
16use MediaWiki\Language\Language;
17use MediaWiki\Language\LanguageConverterFactory;
18use MediaWiki\Language\MessageLocalizer;
19use MediaWiki\Linker\LinkRenderer;
20use MediaWiki\Linker\LinksMigration;
21use MediaWiki\Linker\LinkTarget;
22use MediaWiki\MainConfigNames;
23use MediaWiki\Page\LinkBatchFactory;
24use MediaWiki\Page\PageIdentity;
25use MediaWiki\Page\PageReference;
26use MediaWiki\ParamValidator\TypeDef\TitleDef;
27use MediaWiki\Permissions\PermissionStatus;
28use MediaWiki\Permissions\RestrictionStore;
29use MediaWiki\Revision\RevisionLookup;
30use MediaWiki\Title\NamespaceInfo;
31use MediaWiki\Title\Title;
32use MediaWiki\Title\TitleFactory;
33use MediaWiki\Title\TitleFormatter;
34use MediaWiki\Title\TitleValue;
35use MediaWiki\User\TempUser\TempUserCreator;
36use MediaWiki\User\UserFactory;
37use MediaWiki\Utils\UrlUtils;
38use MediaWiki\Watchlist\WatchedItemStore;
39use Wikimedia\ParamValidator\ParamValidator;
40use Wikimedia\ParamValidator\TypeDef\EnumDef;
41use Wikimedia\Timestamp\TimestampFormat as TS;
42
43/**
44 * A query module to show basic page information.
45 *
46 * @ingroup API
47 */
48class ApiQueryInfo extends ApiQueryBase {
49
50    private ILanguageConverter $languageConverter;
51
52    private bool $fld_protection = false;
53    private bool $fld_talkid = false;
54    private bool $fld_subjectid = false;
55    private bool $fld_url = false;
56    private bool $fld_readable = false;
57    private bool $fld_watched = false;
58    private bool $fld_watchlistlabels = false;
59    private bool $fld_watchers = false;
60    private bool $fld_visitingwatchers = false;
61    private bool $fld_notificationtimestamp = false;
62    private bool $fld_preload = false;
63    private bool $fld_preloadcontent = false;
64    private bool $fld_editintro = false;
65    private bool $fld_displaytitle = false;
66    private bool $fld_varianttitles = false;
67
68    /**
69     * @var bool Whether to include link class information for the
70     *    given page titles.
71     */
72    private $fld_linkclasses = false;
73
74    /**
75     * @var bool Whether to include the name of the associated page
76     */
77    private $fld_associatedpage = false;
78
79    /** @var array */
80    private $params;
81
82    /** @var array<int,PageIdentity> */
83    private $titles;
84    /** @var array<int,PageIdentity> */
85    private $missing;
86    /** @var array<int,PageIdentity> */
87    private $everything;
88
89    /**
90     * @var array<int,bool> [page_id] => page_is_redirect database field, guaranteed to be
91     * initialized via execute()
92     */
93    private $pageIsRedir;
94    /**
95     * @var array<int,bool> [page_id] => page_is_new database field, guaranteed to be
96     * initialized via execute()
97     */
98    private $pageIsNew;
99    /**
100     * @var array<int,int> [page_id] => page_touched database field, guaranteed to be
101     * initialized via execute()
102     */
103    private $pageTouched;
104    /**
105     * @var array<int,int> [page_id] => page_latest database field, guaranteed to be
106     * initialized via execute()
107     */
108    private $pageLatest;
109    /**
110     * @var array<int,int> [page_id] => page_len database field, guaranteed to be
111     * initialized via execute()
112     */
113    private $pageLength;
114
115    /** @var array[][][] */
116    private $protections;
117    /** @var string[][][] */
118    private $restrictionTypes;
119    /** @var bool[][] */
120    private $watched;
121    /** @var array[][][] */
122    private $watchlistLabels;
123    /** @var int[][] */
124    private $watchers;
125    /** @var int[][] */
126    private $visitingwatchers;
127    /** @var string[][] */
128    private $notificationtimestamps;
129    /** @var int[][] */
130    private $talkids;
131    /** @var int[][] */
132    private $subjectids;
133    /** @var string[][] */
134    private $displaytitles;
135    /** @var string[][] */
136    private $variantTitles;
137
138    /**
139     * Watchlist expiries that corresponds with the $watched property. Keyed by namespace and title.
140     * @var array<int,array<string,string>>
141     */
142    private $watchlistExpiries;
143
144    /**
145     * @var array<int,string[]> Mapping of page id to list of 'extra link
146     *   classes' for the given page
147     */
148    private $linkClasses;
149
150    /** @var bool */
151    private $showZeroWatchers = false;
152
153    /** @var int */
154    private $countTestedActions = 0;
155
156    public function __construct(
157        ApiQuery $queryModule,
158        string $moduleName,
159        Language $contentLanguage,
160        private readonly LinkBatchFactory $linkBatchFactory,
161        private readonly NamespaceInfo $namespaceInfo,
162        private readonly TitleFactory $titleFactory,
163        private readonly TitleFormatter $titleFormatter,
164        private readonly WatchedItemStore $watchedItemStore,
165        LanguageConverterFactory $languageConverterFactory,
166        private readonly RestrictionStore $restrictionStore,
167        private readonly LinksMigration $linksMigration,
168        private readonly TempUserCreator $tempUserCreator,
169        private readonly UserFactory $userFactory,
170        private readonly IntroMessageBuilder $introMessageBuilder,
171        private readonly PreloadedContentBuilder $preloadedContentBuilder,
172        private readonly RevisionLookup $revisionLookup,
173        private readonly UrlUtils $urlUtils,
174        private readonly LinkRenderer $linkRenderer,
175    ) {
176        parent::__construct( $queryModule, $moduleName, 'in' );
177        $this->languageConverter = $languageConverterFactory->getLanguageConverter( $contentLanguage );
178    }
179
180    /**
181     * @param ApiPageSet $pageSet
182     * @return void
183     */
184    public function requestExtraData( $pageSet ) {
185        // If the pageset is resolving redirects we won't get page_is_redirect.
186        // But we can't know for sure until the pageset is executed (revids may
187        // turn it off), so request it unconditionally.
188        $pageSet->requestField( 'page_is_redirect' );
189        $pageSet->requestField( 'page_is_new' );
190        $config = $this->getConfig();
191        $pageSet->requestField( 'page_touched' );
192        $pageSet->requestField( 'page_latest' );
193        $pageSet->requestField( 'page_len' );
194        $pageSet->requestField( 'page_content_model' );
195        if ( $config->get( MainConfigNames::PageLanguageUseDB ) ) {
196            $pageSet->requestField( 'page_lang' );
197        }
198    }
199
200    public function execute() {
201        $this->params = $this->extractRequestParams();
202        if ( $this->params['prop'] !== null ) {
203            $prop = array_fill_keys( $this->params['prop'], true );
204            $this->fld_protection = isset( $prop['protection'] );
205            $this->fld_watched = isset( $prop['watched'] );
206            $this->fld_watchlistlabels = isset( $prop['watchlistlabels'] )
207                && $this->getConfig()->get( MainConfigNames::EnableWatchlistLabels );
208            $this->fld_watchers = isset( $prop['watchers'] );
209            $this->fld_visitingwatchers = isset( $prop['visitingwatchers'] );
210            $this->fld_notificationtimestamp = isset( $prop['notificationtimestamp'] );
211            $this->fld_talkid = isset( $prop['talkid'] );
212            $this->fld_subjectid = isset( $prop['subjectid'] );
213            $this->fld_url = isset( $prop['url'] );
214            $this->fld_readable = isset( $prop['readable'] );
215            $this->fld_preload = isset( $prop['preload'] );
216            $this->fld_preloadcontent = isset( $prop['preloadcontent'] );
217            $this->fld_editintro = isset( $prop['editintro'] );
218            $this->fld_displaytitle = isset( $prop['displaytitle'] );
219            $this->fld_varianttitles = isset( $prop['varianttitles'] );
220            $this->fld_linkclasses = isset( $prop['linkclasses'] );
221            $this->fld_associatedpage = isset( $prop['associatedpage'] );
222        }
223
224        $pageSet = $this->getPageSet();
225        $this->titles = $pageSet->getGoodPages();
226        $this->missing = $pageSet->getMissingPages();
227        $this->everything = $this->titles + $this->missing;
228        $result = $this->getResult();
229
230        if (
231            ( $this->fld_preloadcontent || $this->fld_editintro ) &&
232            ( count( $this->everything ) > 1 || count( $this->getPageSet()->getRevisionIDs() ) > 1 )
233        ) {
234            // This is relatively slow, so disallow doing it for multiple pages, just in case.
235            // (Also, handling multiple revisions would be tricky.)
236            $this->dieWithError(
237                [ 'apierror-info-singlepagerevision', $this->getModulePrefix() ], 'invalidparammix'
238            );
239        }
240
241        uasort( $this->everything, Title::compare( ... ) );
242        if ( $this->params['continue'] !== null ) {
243            // Throw away any titles we're gonna skip so they don't
244            // clutter queries
245            $cont = $this->parseContinueParamOrDie( $this->params['continue'], [ 'int', 'string' ] );
246            $conttitle = $this->titleFactory->makeTitleSafe( $cont[0], $cont[1] );
247            $this->dieContinueUsageIf( !$conttitle );
248            foreach ( $this->everything as $pageid => $page ) {
249                if ( Title::compare( $page, $conttitle ) >= 0 ) {
250                    break;
251                }
252                unset( $this->titles[$pageid] );
253                unset( $this->missing[$pageid] );
254                unset( $this->everything[$pageid] );
255            }
256        }
257
258        // when resolving redirects, no page will have this field
259        $this->pageIsRedir = !$pageSet->isResolvingRedirects()
260            ? $pageSet->getCustomField( 'page_is_redirect' )
261            : [];
262        $this->pageIsNew = $pageSet->getCustomField( 'page_is_new' );
263
264        $this->pageTouched = $pageSet->getCustomField( 'page_touched' );
265        $this->pageLatest = $pageSet->getCustomField( 'page_latest' );
266        $this->pageLength = $pageSet->getCustomField( 'page_len' );
267
268        // Get protection info if requested
269        if ( $this->fld_protection ) {
270            $this->getProtectionInfo();
271        }
272
273        if ( $this->fld_watched || $this->fld_watchlistlabels || $this->fld_notificationtimestamp ) {
274            $this->getWatchedInfo();
275        }
276
277        if ( $this->fld_watchers ) {
278            $this->getWatcherInfo();
279        }
280
281        if ( $this->fld_visitingwatchers ) {
282            $this->getVisitingWatcherInfo();
283        }
284
285        // Run the talkid/subjectid query if requested
286        if ( $this->fld_talkid || $this->fld_subjectid ) {
287            $this->getTSIDs();
288        }
289
290        if ( $this->fld_displaytitle ) {
291            $this->getDisplayTitle();
292        }
293
294        if ( $this->fld_varianttitles ) {
295            $this->getVariantTitles();
296        }
297
298        if ( $this->fld_linkclasses ) {
299            $this->getLinkClasses( $this->params['linkcontext'], $this->params['defaultlinkcaption'] );
300        }
301
302        /** @var PageIdentity $page */
303        foreach ( $this->everything as $pageid => $page ) {
304            $pageInfo = $this->extractPageInfo( $pageid, $page );
305            $fit = $pageInfo !== null && $result->addValue( [
306                'query',
307                'pages'
308            ], $pageid, $pageInfo );
309            if ( !$fit ) {
310                $this->setContinueEnumParameter( 'continue',
311                    $page->getNamespace() . '|' .
312                    $this->titleFormatter->getText( $page ) );
313                break;
314            }
315        }
316    }
317
318    /**
319     * Get a result array with information about a title
320     * @param int $pageid Page ID (negative for missing titles)
321     * @param PageIdentity $page
322     * @return array|null
323     */
324    private function extractPageInfo( $pageid, $page ) {
325        $title = $this->titleFactory->newFromPageIdentity( $page );
326        $pageInfo = [];
327        // $page->exists() needs pageid, which is not set for all title objects
328        $pageExists = $pageid > 0;
329        $ns = $page->getNamespace();
330        $dbkey = $page->getDBkey();
331
332        $pageInfo['contentmodel'] = $title->getContentModel();
333
334        $pageLanguage = $title->getPageLanguage();
335        $pageInfo['pagelanguage'] = $pageLanguage->getCode();
336        $pageInfo['pagelanguagehtmlcode'] = $pageLanguage->getHtmlCode();
337        $pageInfo['pagelanguagedir'] = $pageLanguage->getDir();
338
339        if ( $pageExists ) {
340            $pageInfo['touched'] = wfTimestamp( TS::ISO_8601, $this->pageTouched[$pageid] );
341            $pageInfo['lastrevid'] = (int)$this->pageLatest[$pageid];
342            $pageInfo['length'] = (int)$this->pageLength[$pageid];
343
344            if ( isset( $this->pageIsRedir[$pageid] ) && $this->pageIsRedir[$pageid] ) {
345                $pageInfo['redirect'] = true;
346            }
347            if ( $this->pageIsNew[$pageid] ) {
348                $pageInfo['new'] = true;
349            }
350        }
351
352        if ( $this->fld_protection ) {
353            $pageInfo['protection'] = [];
354            if ( isset( $this->protections[$ns][$dbkey] ) ) {
355                $pageInfo['protection'] =
356                    $this->protections[$ns][$dbkey];
357            }
358            ApiResult::setIndexedTagName( $pageInfo['protection'], 'pr' );
359
360            $pageInfo['restrictiontypes'] = [];
361            if ( isset( $this->restrictionTypes[$ns][$dbkey] ) ) {
362                $pageInfo['restrictiontypes'] =
363                    $this->restrictionTypes[$ns][$dbkey];
364            }
365            ApiResult::setIndexedTagName( $pageInfo['restrictiontypes'], 'rt' );
366        }
367
368        if ( $this->fld_watched ) {
369            $pageInfo['watched'] = false;
370
371            if ( isset( $this->watched[$ns][$dbkey] ) ) {
372                $pageInfo['watched'] = $this->watched[$ns][$dbkey];
373            }
374
375            if ( isset( $this->watchlistExpiries[$ns][$dbkey] ) ) {
376                $pageInfo['watchlistexpiry'] = $this->watchlistExpiries[$ns][$dbkey];
377            }
378        }
379
380        if ( $this->fld_watchlistlabels ) {
381            $pageInfo['watchlistlabels'] = [];
382            if ( isset( $this->watchlistLabels[$ns][$dbkey] ) ) {
383                $pageInfo['watchlistlabels'] = $this->watchlistLabels[$ns][$dbkey];
384            }
385            ApiResult::setIndexedTagName( $pageInfo['watchlistlabels'], 'label' );
386        }
387
388        if ( $this->fld_watchers ) {
389            if ( $this->watchers !== null && $this->watchers[$ns][$dbkey] !== 0 ) {
390                $pageInfo['watchers'] = $this->watchers[$ns][$dbkey];
391            } elseif ( $this->showZeroWatchers ) {
392                $pageInfo['watchers'] = 0;
393            }
394        }
395
396        if ( $this->fld_visitingwatchers ) {
397            if ( $this->visitingwatchers !== null && $this->visitingwatchers[$ns][$dbkey] !== 0 ) {
398                $pageInfo['visitingwatchers'] = $this->visitingwatchers[$ns][$dbkey];
399            } elseif ( $this->showZeroWatchers ) {
400                $pageInfo['visitingwatchers'] = 0;
401            }
402        }
403
404        if ( $this->fld_notificationtimestamp ) {
405            $pageInfo['notificationtimestamp'] = '';
406            if ( isset( $this->notificationtimestamps[$ns][$dbkey] ) ) {
407                $pageInfo['notificationtimestamp'] =
408                    wfTimestamp( TS::ISO_8601, $this->notificationtimestamps[$ns][$dbkey] );
409            }
410        }
411
412        if ( $this->fld_talkid && isset( $this->talkids[$ns][$dbkey] ) ) {
413            $pageInfo['talkid'] = $this->talkids[$ns][$dbkey];
414        }
415
416        if ( $this->fld_subjectid && isset( $this->subjectids[$ns][$dbkey] ) ) {
417            $pageInfo['subjectid'] = $this->subjectids[$ns][$dbkey];
418        }
419
420        if ( $this->fld_associatedpage && $ns >= NS_MAIN ) {
421            $pageInfo['associatedpage'] = $this->titleFormatter->getPrefixedText(
422                $this->namespaceInfo->getAssociatedPage( TitleValue::newFromPage( $page ) )
423            );
424        }
425
426        if ( $this->fld_url ) {
427            $pageInfo['fullurl'] = (string)$this->urlUtils->expand(
428                $title->getFullURL(), PROTO_CURRENT
429            );
430            $pageInfo['editurl'] = (string)$this->urlUtils->expand(
431                $title->getFullURL( 'action=edit' ), PROTO_CURRENT
432            );
433            $pageInfo['canonicalurl'] = (string)$this->urlUtils->expand(
434                $title->getFullURL(), PROTO_CANONICAL
435            );
436        }
437        if ( $this->fld_readable ) {
438            $pageInfo['readable'] = $this->getAuthority()->definitelyCan( 'read', $page );
439        }
440
441        if ( $this->fld_preload ) {
442            $text = '';
443            if ( !$pageExists ) {
444                $this->getHookRunner()->onEditFormPreloadText( $text, $title );
445            }
446            $pageInfo['preload'] = $text;
447        }
448
449        if ( $this->fld_preloadcontent ) {
450            $newSection = $this->params['preloadnewsection'];
451            // Preloaded content is not supported for already existing pages or sections.
452            // The actual page/section content should be shown for editing (from prop=revisions API).
453            if ( !$pageExists || $newSection ) {
454                $content = $this->preloadedContentBuilder->getPreloadedContent(
455                    $title->toPageIdentity(),
456                    $this->getAuthority(),
457                    $this->params['preloadcustom'],
458                    $this->params['preloadparams'] ?? [],
459                    $newSection ? 'new' : null
460                );
461                $defaultContent = $newSection ? null :
462                    $this->preloadedContentBuilder->getDefaultContent( $title->toPageIdentity() );
463                $contentIsDefault = $defaultContent ? $content->equals( $defaultContent ) : $content->isEmpty();
464                // Adapted from ApiQueryRevisionsBase::extractAllSlotInfo.
465                // The preloaded content fills the main slot.
466                $pageInfo['preloadcontent']['contentmodel'] = $content->getModel();
467                $pageInfo['preloadcontent']['contentformat'] = $content->getDefaultFormat();
468                ApiResult::setContentValue( $pageInfo['preloadcontent'], 'content', $content->serialize() );
469                // If the preloaded content generated from these parameters is the same as
470                // the default page content, the user should be discouraged from saving the page
471                // (e.g. by disabling the save button until changes are made, or displaying a warning).
472                $pageInfo['preloadisdefault'] = $contentIsDefault;
473            }
474        }
475
476        if ( $this->fld_editintro ) {
477            // Use $page as the context page in every processed message (T300184)
478            $localizerWithPage = new class( $this, $page ) implements MessageLocalizer {
479                private MessageLocalizer $base;
480                private PageReference $page;
481
482                public function __construct( MessageLocalizer $base, PageReference $page ) {
483                    $this->base = $base;
484                    $this->page = $page;
485                }
486
487                /**
488                 * @inheritDoc
489                 */
490                public function msg( $key, ...$params ) {
491                    return $this->base->msg( $key, ...$params )->page( $this->page );
492                }
493            };
494
495            $styleParamMap = [
496                'lessframes' => IntroMessageBuilder::LESS_FRAMES,
497                'moreframes' => IntroMessageBuilder::MORE_FRAMES,
498            ];
499            // If we got here, there is exactly one page and revision in the query
500            $revId = array_key_first( $this->getPageSet()->getLiveRevisionIDs() );
501            $revRecord = $revId ? $this->revisionLookup->getRevisionById( $revId ) : null;
502
503            $messages = $this->introMessageBuilder->getIntroMessages(
504                $styleParamMap[ $this->params['editintrostyle'] ],
505                $this->params['editintroskip'] ?? [],
506                $localizerWithPage,
507                $title->toPageIdentity(),
508                $revRecord,
509                $this->getAuthority(),
510                $this->params['editintrocustom'],
511                // Maybe expose these as parameters in the future, but for now it doesn't seem worth it:
512                null,
513                false
514            );
515            ApiResult::setIndexedTagName( $messages, 'ei' );
516            ApiResult::setArrayType( $messages, 'kvp', 'key' );
517
518            $pageInfo['editintro'] = $messages;
519        }
520
521        if ( $this->fld_displaytitle ) {
522            $pageInfo['displaytitle'] = $this->displaytitles[$pageid] ??
523                htmlspecialchars( $this->titleFormatter->getPrefixedText( $page ), ENT_NOQUOTES );
524        }
525
526        if ( $this->fld_varianttitles && isset( $this->variantTitles[$pageid] ) ) {
527            $pageInfo['varianttitles'] = $this->variantTitles[$pageid];
528        }
529
530        if ( $this->fld_linkclasses && isset( $this->linkClasses[$pageid] ) ) {
531            $pageInfo['linkclasses'] = $this->linkClasses[$pageid];
532        }
533
534        if ( $this->params['testactions'] ) {
535            $limit = $this->getMain()->canApiHighLimits() ? self::LIMIT_SML2 : self::LIMIT_SML1;
536            if ( $this->countTestedActions >= $limit ) {
537                return null; // force a continuation
538            }
539
540            $detailLevel = $this->params['testactionsdetail'];
541            $errorFormatter = $this->getErrorFormatter();
542            if ( $errorFormatter->getFormat() === 'bc' ) {
543                // Eew, no. Use a more modern format here.
544                $errorFormatter = $errorFormatter->newWithFormat( 'plaintext' );
545            }
546
547            $pageInfo['actions'] = [];
548            if ( $this->params['testactionsautocreate'] ) {
549                $pageInfo['wouldautocreate'] = [];
550            }
551
552            foreach ( $this->params['testactions'] as $action ) {
553                $this->countTestedActions++;
554
555                $shouldAutoCreate = $this->tempUserCreator->shouldAutoCreate( $this->getUser(), $action );
556
557                if ( $shouldAutoCreate ) {
558                    $authority = $this->userFactory->newTempPlaceholder();
559                } else {
560                    $authority = $this->getAuthority();
561                }
562
563                if ( $detailLevel === 'boolean' ) {
564                    $pageInfo['actions'][$action] = $authority->definitelyCan( $action, $page );
565                } else {
566                    $status = new PermissionStatus();
567                    if ( $detailLevel === 'quick' ) {
568                        $authority->probablyCan( $action, $page, $status );
569                    } else {
570                        $authority->definitelyCan( $action, $page, $status );
571                    }
572
573                    $pageInfo['actions'][$action] = $errorFormatter->arrayFromStatus( $status );
574                }
575
576                if ( $this->params['testactionsautocreate'] ) {
577                    $pageInfo['wouldautocreate'][$action] = $shouldAutoCreate;
578                }
579            }
580        }
581
582        return $pageInfo;
583    }
584
585    /**
586     * Get information about protections and put it in $protections
587     */
588    private function getProtectionInfo() {
589        $this->protections = [];
590
591        // Get normal protections for existing titles
592        if ( count( $this->titles ) ) {
593            $this->resetQueryParams();
594            $this->addTables( 'page_restrictions' );
595            $this->addFields( [ 'pr_page', 'pr_type', 'pr_level',
596                'pr_expiry', 'pr_cascade' ] );
597            $this->addWhereFld( 'pr_page', array_keys( $this->titles ) );
598
599            $res = $this->select( __METHOD__ );
600            foreach ( $res as $row ) {
601                /** @var PageReference $page */
602                $page = $this->titles[$row->pr_page];
603                $a = [
604                    'type' => $row->pr_type,
605                    'level' => $row->pr_level,
606                    'expiry' => ApiResult::formatExpiry( $row->pr_expiry )
607                ];
608                if ( $row->pr_cascade ) {
609                    $a['cascade'] = true;
610                }
611                $this->protections[$page->getNamespace()][$page->getDBkey()][] = $a;
612            }
613        }
614
615        // Get protections for missing titles
616        if ( count( $this->missing ) ) {
617            $this->resetQueryParams();
618            $lb = $this->linkBatchFactory->newLinkBatch( $this->missing );
619            $this->addTables( 'protected_titles' );
620            $this->addFields( [ 'pt_title', 'pt_namespace', 'pt_create_perm', 'pt_expiry' ] );
621            $this->addWhere( $lb->constructSet( 'pt', $this->getDB() ) );
622            $res = $this->select( __METHOD__ );
623            foreach ( $res as $row ) {
624                $this->protections[$row->pt_namespace][$row->pt_title][] = [
625                    'type' => 'create',
626                    'level' => $row->pt_create_perm,
627                    'expiry' => ApiResult::formatExpiry( $row->pt_expiry )
628                ];
629            }
630        }
631
632        // Separate good and missing titles into files and other pages
633        // and populate $this->restrictionTypes
634        $images = $others = [];
635        foreach ( $this->everything as $page ) {
636            if ( $page->getNamespace() === NS_FILE ) {
637                $images[] = $page->getDBkey();
638            } else {
639                $others[] = $page;
640            }
641            // Applicable protection types
642            $this->restrictionTypes[$page->getNamespace()][$page->getDBkey()] =
643                array_values( $this->restrictionStore->listApplicableRestrictionTypes( $page ) );
644        }
645
646        if ( count( $others ) ) {
647            $this->resetQueryParams();
648            $this->addTables( [ 'page_restrictions', 'page' ] );
649            $this->addFields( [ 'pr_type', 'pr_level', 'pr_expiry',
650                'page_title', 'page_namespace', 'page_id' ] );
651            $this->addWhere( 'pr_page = page_id' );
652            $this->addWhereFld( 'pr_cascade', 1 );
653
654            $res = $this->select( __METHOD__ );
655
656            $protectedPages = [];
657            foreach ( $res as $row ) {
658                $protectedPages[$row->page_id] = [
659                    'type' => $row->pr_type,
660                    'level' => $row->pr_level,
661                    'expiry' => ApiResult::formatExpiry( $row->pr_expiry ),
662                    'source' => $this->titleFormatter->formatTitle( $row->page_namespace, $row->page_title ),
663                ];
664            }
665
666            if ( $protectedPages ) {
667                $this->setVirtualDomain( TemplateLinksTable::VIRTUAL_DOMAIN );
668
669                $lb = $this->linkBatchFactory->newLinkBatch( $others );
670
671                $queryInfo = $this->linksMigration->getQueryInfo( 'templatelinks' );
672                $res = $this->getDB()->newSelectQueryBuilder()
673                    ->select( [ 'tl_from', 'lt_namespace', 'lt_title' ] )
674                    ->tables( $queryInfo['tables'] )
675                    ->joinConds( $queryInfo['joins'] )
676                    ->where( [ 'tl_from' => array_keys( $protectedPages ) ] )
677                    ->andWhere( $lb->constructSet( 'tl', $this->getDB() ) )
678                    ->useIndex( [ 'templatelinks' => 'PRIMARY' ] )
679                    ->caller( __METHOD__ )
680                    ->fetchResultSet();
681
682                foreach ( $res as $row ) {
683                    $protection = $protectedPages[$row->tl_from];
684                    $this->protections[$row->lt_namespace][$row->lt_title][] = $protection;
685                }
686
687                $this->resetVirtualDomain();
688            }
689        }
690
691        if ( count( $images ) ) {
692            $this->resetQueryParams();
693            $this->addTables( [ 'page_restrictions', 'page' ] );
694            $this->addFields( [ 'pr_type', 'pr_level', 'pr_expiry',
695                'page_title', 'page_namespace', 'page_id' ] );
696            $this->addWhere( 'pr_page = page_id' );
697            $this->addWhereFld( 'pr_cascade', 1 );
698
699            $res = $this->select( __METHOD__ );
700
701            $protectedPages = [];
702            foreach ( $res as $row ) {
703                $protectedPages[$row->page_id] = [
704                    'type' => $row->pr_type,
705                    'level' => $row->pr_level,
706                    'expiry' => ApiResult::formatExpiry( $row->pr_expiry ),
707                    'source' => $this->titleFormatter->formatTitle( $row->page_namespace, $row->page_title ),
708                ];
709            }
710
711            if ( $protectedPages ) {
712                $this->setVirtualDomain( ImageLinksTable::VIRTUAL_DOMAIN );
713
714                $queryInfo = $this->linksMigration->getQueryInfo( 'imagelinks' );
715
716                $res = $this->getDB()->newSelectQueryBuilder()
717                    ->fields( [ 'il_from', 'lt_title' ] )
718                    ->tables( $queryInfo['tables'] )
719                    ->where( [ 'il_from' => array_keys( $protectedPages ) ] )
720                    ->joinConds( $queryInfo['joins'] )
721                    ->andWhere( [ 'lt_title' => $images, 'lt_namespace' => NS_FILE ] )
722                    // Prevent RDBMS from picking the il_target_id index here, which can cause
723                    // massive table scans in the case of linking to a highly used image.
724                    ->useIndex( [ 'imagelinks' => 'PRIMARY' ] )
725                    ->caller( __METHOD__ )
726                    ->fetchResultSet();
727
728                foreach ( $res as $row ) {
729                    $protection = $protectedPages[$row->il_from];
730                    $this->protections[NS_FILE][$row->lt_title][] = $protection;
731                }
732
733                $this->resetVirtualDomain();
734            }
735        }
736    }
737
738    /**
739     * Get talk page IDs (if requested) and subject page IDs (if requested)
740     * and put them in $talkids and $subjectids
741     */
742    private function getTSIDs() {
743        $getTitles = $this->talkids = $this->subjectids = [];
744        $nsInfo = $this->namespaceInfo;
745
746        /** @var PageReference $page */
747        foreach ( $this->everything as $page ) {
748            if ( $nsInfo->isTalk( $page->getNamespace() ) ) {
749                if ( $this->fld_subjectid ) {
750                    $getTitles[] = $nsInfo->getSubjectPage( TitleValue::newFromPage( $page ) );
751                }
752            } elseif ( $this->fld_talkid ) {
753                $getTitles[] = $nsInfo->getTalkPage( TitleValue::newFromPage( $page ) );
754            }
755        }
756        if ( $getTitles === [] ) {
757            return;
758        }
759
760        $db = $this->getDB();
761
762        // Construct a custom WHERE clause that matches
763        // all titles in $getTitles
764        $lb = $this->linkBatchFactory->newLinkBatch( $getTitles );
765        $this->resetQueryParams();
766        $this->addTables( 'page' );
767        $this->addFields( [ 'page_title', 'page_namespace', 'page_id' ] );
768        $this->addWhere( $lb->constructSet( 'page', $db ) );
769        $res = $this->select( __METHOD__ );
770        foreach ( $res as $row ) {
771            if ( $nsInfo->isTalk( $row->page_namespace ) ) {
772                $this->talkids[$nsInfo->getSubject( $row->page_namespace )][$row->page_title] =
773                    (int)( $row->page_id );
774            } else {
775                $this->subjectids[$nsInfo->getTalk( $row->page_namespace )][$row->page_title] =
776                    (int)( $row->page_id );
777            }
778        }
779    }
780
781    private function getDisplayTitle() {
782        $this->displaytitles = [];
783
784        $pageIds = array_keys( $this->titles );
785
786        if ( $pageIds === [] ) {
787            return;
788        }
789
790        $this->resetQueryParams();
791        $this->addTables( 'page_props' );
792        $this->addFields( [ 'pp_page', 'pp_value' ] );
793        $this->addWhereFld( 'pp_page', $pageIds );
794        $this->addWhereFld( 'pp_propname', 'displaytitle' );
795        $res = $this->select( __METHOD__ );
796
797        foreach ( $res as $row ) {
798            $this->displaytitles[$row->pp_page] = $row->pp_value;
799        }
800    }
801
802    /**
803     * Fetch the set of extra link classes associated with links to the
804     * set of titles ("link colours"), as they would appear on the
805     * given context page.
806     * @param ?LinkTarget $context_title The page context in which link
807     *   colors are determined.
808     * @param bool $default_link_caption Whether to treat links as having
809     *   default captions for the purpose of determining link colors.
810     *   Default caption is defined as for {@see LinkRenderer::isDefaultLinkCaption()}.
811     */
812    private function getLinkClasses( ?LinkTarget $context_title = null, bool $default_link_caption = false ) {
813        if ( $this->titles === [] ) {
814            return;
815        }
816        // For compatibility with legacy GetLinkColours hook:
817        // $pagemap maps from page id to title (as prefixed db key)
818        // $classes maps from title (prefixed db key) to a space-separated
819        //   list of link classes ("link colours").
820        // The hook should not modify $pagemap, and should only append to
821        // $classes (being careful to maintain space separation).
822        $classes = [];
823        $pagemap = [];
824        foreach ( $this->titles as $pageId => $page ) {
825            $pdbk = $this->titleFormatter->getPrefixedDBkey( $page );
826            $pagemap[$pageId] = $pdbk;
827            $classes[$pdbk] = $this->linkRenderer->getLinkClasses( $page, $default_link_caption );
828        }
829        // legacy hook requires a real Title, not a LinkTarget
830        $context_title = $this->titleFactory->newFromLinkTarget(
831            $context_title ?? $this->titleFactory->newMainPage()
832        );
833        $this->getHookRunner()->onGetLinkColours(
834            $pagemap, $classes, $context_title
835        );
836
837        // This API class expects the class list to be:
838        //  (a) indexed by pageid, not title, and
839        //  (b) a proper array of strings (possibly zero-length),
840        //      not a single space-separated string (possibly the empty string)
841        $this->linkClasses = [];
842        foreach ( $this->titles as $pageId => $page ) {
843            $pdbk = $this->titleFormatter->getPrefixedDBkey( $page );
844            $this->linkClasses[$pageId] = preg_split(
845                '/\s+/', $classes[$pdbk] ?? '', -1, PREG_SPLIT_NO_EMPTY
846            );
847        }
848    }
849
850    private function getVariantTitles() {
851        if ( $this->titles === [] ) {
852            return;
853        }
854        $this->variantTitles = [];
855        foreach ( $this->titles as $pageId => $page ) {
856            $this->variantTitles[$pageId] = isset( $this->displaytitles[$pageId] )
857                ? $this->getAllVariants( $this->displaytitles[$pageId] )
858                : $this->getAllVariants( $this->titleFormatter->getText( $page ), $page->getNamespace() );
859        }
860    }
861
862    private function getAllVariants( string $text, int $ns = NS_MAIN ): array {
863        $result = [];
864        foreach ( $this->languageConverter->getVariants() as $variant ) {
865            $convertTitle = $this->languageConverter->autoConvert( $text, $variant );
866            if ( $ns !== NS_MAIN ) {
867                $convertNs = $this->languageConverter->convertNamespace( $ns, $variant );
868                $convertTitle = $convertNs . ':' . $convertTitle;
869            }
870            $result[$variant] = $convertTitle;
871        }
872        return $result;
873    }
874
875    /**
876     * Get information about watched status and put it in $this->watched
877     * and $this->notificationtimestamps
878     */
879    private function getWatchedInfo() {
880        $user = $this->getUser();
881
882        if ( !$user->isRegistered() || count( $this->everything ) == 0
883            || !$this->getAuthority()->isAllowed( 'viewmywatchlist' )
884        ) {
885            return;
886        }
887
888        $this->watched = [];
889        $this->watchlistExpiries = [];
890        $this->watchlistLabels = [];
891        $this->notificationtimestamps = [];
892
893        $items = $this->watchedItemStore->loadWatchedItemsBatch( $user, $this->everything );
894
895        foreach ( $items as $item ) {
896            $nsId = $item->getTarget()->getNamespace();
897            $dbKey = $item->getTarget()->getDBkey();
898
899            if ( $this->fld_watched ) {
900                $this->watched[$nsId][$dbKey] = true;
901
902                $expiry = $item->getExpiry( TS::ISO_8601 );
903                if ( $expiry ) {
904                    $this->watchlistExpiries[$nsId][$dbKey] = $expiry;
905                }
906            }
907
908            if ( $this->fld_watchlistlabels ) {
909                $labels = $item->getLabels();
910                if ( $labels ) {
911                    $labelData = [];
912                    foreach ( $labels as $label ) {
913                        $labelData[] = [
914                            'id' => $label->getId(),
915                            'name' => $label->getName(),
916                        ];
917                    }
918                    $this->watchlistLabels[$nsId][$dbKey] = $labelData;
919                }
920            }
921
922            if ( $this->fld_notificationtimestamp ) {
923                $this->notificationtimestamps[$nsId][$dbKey] = $item->getNotificationTimestamp();
924            }
925        }
926    }
927
928    /**
929     * Get the count of watchers and put it in $this->watchers
930     */
931    private function getWatcherInfo() {
932        if ( count( $this->everything ) == 0 ) {
933            return;
934        }
935
936        $canUnwatchedpages = $this->getAuthority()->isAllowed( 'unwatchedpages' );
937        $unwatchedPageThreshold =
938            $this->getConfig()->get( MainConfigNames::UnwatchedPageThreshold );
939        if ( !$canUnwatchedpages && !is_int( $unwatchedPageThreshold ) ) {
940            return;
941        }
942
943        $this->showZeroWatchers = $canUnwatchedpages;
944
945        $countOptions = [];
946        if ( !$canUnwatchedpages ) {
947            $countOptions['minimumWatchers'] = $unwatchedPageThreshold;
948        }
949
950        $this->watchers = $this->watchedItemStore->countWatchersMultiple(
951            $this->everything,
952            $countOptions
953        );
954    }
955
956    /**
957     * Get the count of watchers who have visited recent edits and put it in
958     * $this->visitingwatchers
959     *
960     * Based on InfoAction::pageCounts
961     */
962    private function getVisitingWatcherInfo() {
963        $config = $this->getConfig();
964        $db = $this->getDB();
965
966        $canUnwatchedpages = $this->getAuthority()->isAllowed( 'unwatchedpages' );
967        $unwatchedPageThreshold = $config->get( MainConfigNames::UnwatchedPageThreshold );
968        if ( !$canUnwatchedpages && !is_int( $unwatchedPageThreshold ) ) {
969            return;
970        }
971
972        $this->showZeroWatchers = $canUnwatchedpages;
973
974        $titlesWithThresholds = [];
975        if ( $this->titles ) {
976            $lb = $this->linkBatchFactory->newLinkBatch( $this->titles );
977
978            // Fetch last edit timestamps for pages
979            $this->resetQueryParams();
980            $this->addTables( [ 'page', 'revision' ] );
981            $this->addFields( [ 'page_namespace', 'page_title', 'rev_timestamp' ] );
982            $this->addWhere( [
983                'page_latest = rev_id',
984                $lb->constructSet( 'page', $db ),
985            ] );
986            $this->addOption( 'GROUP BY', [ 'page_namespace', 'page_title' ] );
987            $timestampRes = $this->select( __METHOD__ );
988
989            $age = $config->get( MainConfigNames::WatchersMaxAge );
990            $timestamps = [];
991            foreach ( $timestampRes as $row ) {
992                $revTimestamp = wfTimestamp( TS::UNIX, (int)$row->rev_timestamp );
993                $timestamps[$row->page_namespace][$row->page_title] = (int)$revTimestamp - $age;
994            }
995            $titlesWithThresholds = array_map(
996                static function ( PageReference $target ) use ( $timestamps ) {
997                    return [
998                        $target, $timestamps[$target->getNamespace()][$target->getDBkey()]
999                    ];
1000                },
1001                $this->titles
1002            );
1003        }
1004
1005        if ( $this->missing ) {
1006            $titlesWithThresholds = array_merge(
1007                $titlesWithThresholds,
1008                array_map(
1009                    static function ( PageReference $target ) {
1010                        return [ $target, null ];
1011                    },
1012                    $this->missing
1013                )
1014            );
1015        }
1016        $this->visitingwatchers = $this->watchedItemStore->countVisitingWatchersMultiple(
1017            $titlesWithThresholds,
1018            !$canUnwatchedpages ? $unwatchedPageThreshold : null
1019        );
1020    }
1021
1022    /** @inheritDoc */
1023    public function getCacheMode( $params ) {
1024        // Other props depend on something about the current user
1025        $publicProps = [
1026            'protection',
1027            'talkid',
1028            'subjectid',
1029            'associatedpage',
1030            'url',
1031            'preload',
1032            'displaytitle',
1033            'varianttitles',
1034        ];
1035        if ( array_diff( (array)$params['prop'], $publicProps ) ) {
1036            return 'private';
1037        }
1038
1039        // testactions also depends on the current user
1040        if ( $params['testactions'] ) {
1041            return 'private';
1042        }
1043
1044        return 'public';
1045    }
1046
1047    /** @inheritDoc */
1048    public function getAllowedParams() {
1049        return [
1050            'prop' => [
1051                ParamValidator::PARAM_ISMULTI => true,
1052                ParamValidator::PARAM_TYPE => [
1053                    'protection',
1054                    'talkid',
1055                    'watched', # private
1056                    'watchlistlabels', # private
1057                    'watchers', # private
1058                    'visitingwatchers', # private
1059                    'notificationtimestamp', # private
1060                    'subjectid',
1061                    'associatedpage',
1062                    'url',
1063                    'readable', # private
1064                    'preload',
1065                    'preloadcontent', # private: checks current user's permissions
1066                    'editintro', # private: checks current user's permissions
1067                    'displaytitle',
1068                    'varianttitles',
1069                    'linkclasses', # private: stub length (and possibly hook colors)
1070                    // If you add more properties here, please consider whether they
1071                    // need to be added to getCacheMode()
1072                ],
1073                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
1074                EnumDef::PARAM_DEPRECATED_VALUES => [
1075                    'readable' => true, // Since 1.32
1076                    'preload' => true, // Since 1.41
1077                ],
1078            ],
1079            'linkcontext' => [
1080                ParamValidator::PARAM_TYPE => 'title',
1081                ParamValidator::PARAM_DEFAULT => $this->titleFactory->newMainPage()->getPrefixedText(),
1082                TitleDef::PARAM_RETURN_OBJECT => true,
1083            ],
1084            'defaultlinkcaption' => [
1085                ParamValidator::PARAM_TYPE => 'boolean',
1086                ParamValidator::PARAM_DEFAULT => false,
1087            ],
1088            'testactions' => [
1089                ParamValidator::PARAM_TYPE => 'string',
1090                ParamValidator::PARAM_ISMULTI => true,
1091            ],
1092            'testactionsdetail' => [
1093                ParamValidator::PARAM_TYPE => [ 'boolean', 'full', 'quick' ],
1094                ParamValidator::PARAM_DEFAULT => 'boolean',
1095                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
1096            ],
1097            'testactionsautocreate' => false,
1098            'preloadcustom' => [
1099                // This should be a valid and existing page title, but we don't want to validate it here,
1100                // because it's usually someone else's fault. It could emit a warning in the future.
1101                ParamValidator::PARAM_TYPE => 'string',
1102                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'preloadcontentonly' ] ],
1103            ],
1104            'preloadparams' => [
1105                ParamValidator::PARAM_ISMULTI => true,
1106                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'preloadcontentonly' ] ],
1107            ],
1108            'preloadnewsection' => [
1109                ParamValidator::PARAM_TYPE => 'boolean',
1110                ParamValidator::PARAM_DEFAULT => false,
1111                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'preloadcontentonly' ] ],
1112            ],
1113            'editintrostyle' => [
1114                ParamValidator::PARAM_TYPE => [ 'lessframes', 'moreframes' ],
1115                ParamValidator::PARAM_DEFAULT => 'moreframes',
1116                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'editintroonly' ] ],
1117            ],
1118            'editintroskip' => [
1119                ParamValidator::PARAM_TYPE => 'string',
1120                ParamValidator::PARAM_ISMULTI => true,
1121                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'editintroonly' ] ],
1122            ],
1123            'editintrocustom' => [
1124                // This should be a valid and existing page title, but we don't want to validate it here,
1125                // because it's usually someone else's fault. It could emit a warning in the future.
1126                ParamValidator::PARAM_TYPE => 'string',
1127                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'editintroonly' ] ],
1128            ],
1129            'continue' => [
1130                ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
1131            ],
1132        ];
1133    }
1134
1135    /** @inheritDoc */
1136    protected function getExamplesMessages() {
1137        $title = Title::newMainPage()->getPrefixedText();
1138        $mp = rawurlencode( $title );
1139
1140        return [
1141            "action=query&prop=info&titles={$mp}"
1142                => 'apihelp-query+info-example-simple',
1143            "action=query&prop=info&inprop=protection&titles={$mp}"
1144                => 'apihelp-query+info-example-protection',
1145        ];
1146    }
1147
1148    /** @inheritDoc */
1149    public function getHelpUrls() {
1150        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Info';
1151    }
1152}
1153
1154/** @deprecated class alias since 1.43 */
1155class_alias( ApiQueryInfo::class, 'ApiQueryInfo' );