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