Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.39% covered (danger)
44.39%
273 / 615
17.65% covered (danger)
17.65%
3 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryInfo
44.46% covered (danger)
44.46%
273 / 614
17.65% covered (danger)
17.65%
3 / 17
4056.82
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.57% covered (danger)
45.57%
72 / 158
0.00% covered (danger)
0.00%
0 / 1
561.71
 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            if ( $pageExists ) {
443                $pageInfo['preload'] = '';
444            } else {
445                $text = null;
446                // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
447                $this->getHookRunner()->onEditFormPreloadText( $text, $title );
448
449                $pageInfo['preload'] = $text;
450            }
451        }
452
453        if ( $this->fld_preloadcontent ) {
454            $newSection = $this->params['preloadnewsection'];
455            // Preloaded content is not supported for already existing pages or sections.
456            // The actual page/section content should be shown for editing (from prop=revisions API).
457            if ( !$pageExists || $newSection ) {
458                $content = $this->preloadedContentBuilder->getPreloadedContent(
459                    $title->toPageIdentity(),
460                    $this->getAuthority(),
461                    $this->params['preloadcustom'],
462                    $this->params['preloadparams'] ?? [],
463                    $newSection ? 'new' : null
464                );
465                $defaultContent = $newSection ? null :
466                    $this->preloadedContentBuilder->getDefaultContent( $title->toPageIdentity() );
467                $contentIsDefault = $defaultContent ? $content->equals( $defaultContent ) : $content->isEmpty();
468                // Adapted from ApiQueryRevisionsBase::extractAllSlotInfo.
469                // The preloaded content fills the main slot.
470                $pageInfo['preloadcontent']['contentmodel'] = $content->getModel();
471                $pageInfo['preloadcontent']['contentformat'] = $content->getDefaultFormat();
472                ApiResult::setContentValue( $pageInfo['preloadcontent'], 'content', $content->serialize() );
473                // If the preloaded content generated from these parameters is the same as
474                // the default page content, the user should be discouraged from saving the page
475                // (e.g. by disabling the save button until changes are made, or displaying a warning).
476                $pageInfo['preloadisdefault'] = $contentIsDefault;
477            }
478        }
479
480        if ( $this->fld_editintro ) {
481            // Use $page as the context page in every processed message (T300184)
482            $localizerWithPage = new class( $this, $page ) implements MessageLocalizer {
483                private MessageLocalizer $base;
484                private PageReference $page;
485
486                public function __construct( MessageLocalizer $base, PageReference $page ) {
487                    $this->base = $base;
488                    $this->page = $page;
489                }
490
491                /**
492                 * @inheritDoc
493                 */
494                public function msg( $key, ...$params ) {
495                    return $this->base->msg( $key, ...$params )->page( $this->page );
496                }
497            };
498
499            $styleParamMap = [
500                'lessframes' => IntroMessageBuilder::LESS_FRAMES,
501                'moreframes' => IntroMessageBuilder::MORE_FRAMES,
502            ];
503            // If we got here, there is exactly one page and revision in the query
504            $revId = array_key_first( $this->getPageSet()->getLiveRevisionIDs() );
505            $revRecord = $revId ? $this->revisionLookup->getRevisionById( $revId ) : null;
506
507            $messages = $this->introMessageBuilder->getIntroMessages(
508                $styleParamMap[ $this->params['editintrostyle'] ],
509                $this->params['editintroskip'] ?? [],
510                $localizerWithPage,
511                $title->toPageIdentity(),
512                $revRecord,
513                $this->getAuthority(),
514                $this->params['editintrocustom'],
515                // Maybe expose these as parameters in the future, but for now it doesn't seem worth it:
516                null,
517                false
518            );
519            ApiResult::setIndexedTagName( $messages, 'ei' );
520            ApiResult::setArrayType( $messages, 'kvp', 'key' );
521
522            $pageInfo['editintro'] = $messages;
523        }
524
525        if ( $this->fld_displaytitle ) {
526            $pageInfo['displaytitle'] = $this->displaytitles[$pageid] ??
527                htmlspecialchars( $this->titleFormatter->getPrefixedText( $page ), ENT_NOQUOTES );
528        }
529
530        if ( $this->fld_varianttitles && isset( $this->variantTitles[$pageid] ) ) {
531            $pageInfo['varianttitles'] = $this->variantTitles[$pageid];
532        }
533
534        if ( $this->fld_linkclasses && isset( $this->linkClasses[$pageid] ) ) {
535            $pageInfo['linkclasses'] = $this->linkClasses[$pageid];
536        }
537
538        if ( $this->params['testactions'] ) {
539            $limit = $this->getMain()->canApiHighLimits() ? self::LIMIT_SML2 : self::LIMIT_SML1;
540            if ( $this->countTestedActions >= $limit ) {
541                return null; // force a continuation
542            }
543
544            $detailLevel = $this->params['testactionsdetail'];
545            $errorFormatter = $this->getErrorFormatter();
546            if ( $errorFormatter->getFormat() === 'bc' ) {
547                // Eew, no. Use a more modern format here.
548                $errorFormatter = $errorFormatter->newWithFormat( 'plaintext' );
549            }
550
551            $pageInfo['actions'] = [];
552            if ( $this->params['testactionsautocreate'] ) {
553                $pageInfo['wouldautocreate'] = [];
554            }
555
556            foreach ( $this->params['testactions'] as $action ) {
557                $this->countTestedActions++;
558
559                $shouldAutoCreate = $this->tempUserCreator->shouldAutoCreate( $this->getUser(), $action );
560
561                if ( $shouldAutoCreate ) {
562                    $authority = $this->userFactory->newTempPlaceholder();
563                } else {
564                    $authority = $this->getAuthority();
565                }
566
567                if ( $detailLevel === 'boolean' ) {
568                    $pageInfo['actions'][$action] = $authority->definitelyCan( $action, $page );
569                } else {
570                    $status = new PermissionStatus();
571                    if ( $detailLevel === 'quick' ) {
572                        $authority->probablyCan( $action, $page, $status );
573                    } else {
574                        $authority->definitelyCan( $action, $page, $status );
575                    }
576
577                    $pageInfo['actions'][$action] = $errorFormatter->arrayFromStatus( $status );
578                }
579
580                if ( $this->params['testactionsautocreate'] ) {
581                    $pageInfo['wouldautocreate'][$action] = $shouldAutoCreate;
582                }
583            }
584        }
585
586        return $pageInfo;
587    }
588
589    /**
590     * Get information about protections and put it in $protections
591     */
592    private function getProtectionInfo() {
593        $this->protections = [];
594
595        // Get normal protections for existing titles
596        if ( count( $this->titles ) ) {
597            $this->resetQueryParams();
598            $this->addTables( 'page_restrictions' );
599            $this->addFields( [ 'pr_page', 'pr_type', 'pr_level',
600                'pr_expiry', 'pr_cascade' ] );
601            $this->addWhereFld( 'pr_page', array_keys( $this->titles ) );
602
603            $res = $this->select( __METHOD__ );
604            foreach ( $res as $row ) {
605                /** @var PageReference $page */
606                $page = $this->titles[$row->pr_page];
607                $a = [
608                    'type' => $row->pr_type,
609                    'level' => $row->pr_level,
610                    'expiry' => ApiResult::formatExpiry( $row->pr_expiry )
611                ];
612                if ( $row->pr_cascade ) {
613                    $a['cascade'] = true;
614                }
615                $this->protections[$page->getNamespace()][$page->getDBkey()][] = $a;
616            }
617        }
618
619        // Get protections for missing titles
620        if ( count( $this->missing ) ) {
621            $this->resetQueryParams();
622            $lb = $this->linkBatchFactory->newLinkBatch( $this->missing );
623            $this->addTables( 'protected_titles' );
624            $this->addFields( [ 'pt_title', 'pt_namespace', 'pt_create_perm', 'pt_expiry' ] );
625            $this->addWhere( $lb->constructSet( 'pt', $this->getDB() ) );
626            $res = $this->select( __METHOD__ );
627            foreach ( $res as $row ) {
628                $this->protections[$row->pt_namespace][$row->pt_title][] = [
629                    'type' => 'create',
630                    'level' => $row->pt_create_perm,
631                    'expiry' => ApiResult::formatExpiry( $row->pt_expiry )
632                ];
633            }
634        }
635
636        // Separate good and missing titles into files and other pages
637        // and populate $this->restrictionTypes
638        $images = $others = [];
639        foreach ( $this->everything as $page ) {
640            if ( $page->getNamespace() === NS_FILE ) {
641                $images[] = $page->getDBkey();
642            } else {
643                $others[] = $page;
644            }
645            // Applicable protection types
646            $this->restrictionTypes[$page->getNamespace()][$page->getDBkey()] =
647                array_values( $this->restrictionStore->listApplicableRestrictionTypes( $page ) );
648        }
649
650        if ( count( $others ) ) {
651            $this->resetQueryParams();
652            $this->addTables( [ 'page_restrictions', 'page' ] );
653            $this->addFields( [ 'pr_type', 'pr_level', 'pr_expiry',
654                'page_title', 'page_namespace', 'page_id' ] );
655            $this->addWhere( 'pr_page = page_id' );
656            $this->addWhereFld( 'pr_cascade', 1 );
657
658            $res = $this->select( __METHOD__ );
659
660            $protectedPages = [];
661            foreach ( $res as $row ) {
662                $protectedPages[$row->page_id] = [
663                    'type' => $row->pr_type,
664                    'level' => $row->pr_level,
665                    'expiry' => ApiResult::formatExpiry( $row->pr_expiry ),
666                    'source' => $this->titleFormatter->formatTitle( $row->page_namespace, $row->page_title ),
667                ];
668            }
669
670            if ( $protectedPages ) {
671                $this->setVirtualDomain( TemplateLinksTable::VIRTUAL_DOMAIN );
672
673                $lb = $this->linkBatchFactory->newLinkBatch( $others );
674
675                $queryInfo = $this->linksMigration->getQueryInfo( 'templatelinks' );
676                $res = $this->getDB()->newSelectQueryBuilder()
677                    ->select( [ 'tl_from', 'lt_namespace', 'lt_title' ] )
678                    ->tables( $queryInfo['tables'] )
679                    ->joinConds( $queryInfo['joins'] )
680                    ->where( [ 'tl_from' => array_keys( $protectedPages ) ] )
681                    ->andWhere( $lb->constructSet( 'tl', $this->getDB() ) )
682                    ->useIndex( [ 'templatelinks' => 'PRIMARY' ] )
683                    ->caller( __METHOD__ )
684                    ->fetchResultSet();
685
686                foreach ( $res as $row ) {
687                    $protection = $protectedPages[$row->tl_from];
688                    $this->protections[$row->lt_namespace][$row->lt_title][] = $protection;
689                }
690
691                $this->resetVirtualDomain();
692            }
693        }
694
695        if ( count( $images ) ) {
696            $this->resetQueryParams();
697            $this->addTables( [ 'page_restrictions', 'page' ] );
698            $this->addFields( [ 'pr_type', 'pr_level', 'pr_expiry',
699                'page_title', 'page_namespace', 'page_id' ] );
700            $this->addWhere( 'pr_page = page_id' );
701            $this->addWhereFld( 'pr_cascade', 1 );
702
703            $res = $this->select( __METHOD__ );
704
705            $protectedPages = [];
706            foreach ( $res as $row ) {
707                $protectedPages[$row->page_id] = [
708                    'type' => $row->pr_type,
709                    'level' => $row->pr_level,
710                    'expiry' => ApiResult::formatExpiry( $row->pr_expiry ),
711                    'source' => $this->titleFormatter->formatTitle( $row->page_namespace, $row->page_title ),
712                ];
713            }
714
715            if ( $protectedPages ) {
716                $this->setVirtualDomain( ImageLinksTable::VIRTUAL_DOMAIN );
717
718                $queryInfo = $this->linksMigration->getQueryInfo( 'imagelinks' );
719
720                $res = $this->getDB()->newSelectQueryBuilder()
721                    ->fields( [ 'il_from', 'lt_title' ] )
722                    ->tables( $queryInfo['tables'] )
723                    ->where( [ 'il_from' => array_keys( $protectedPages ) ] )
724                    ->joinConds( $queryInfo['joins'] )
725                    ->andWhere( [ 'lt_title' => $images, 'lt_namespace' => NS_FILE ] )
726                    // Prevent RDBMS from picking the il_target_id index here, which can cause
727                    // massive table scans in the case of linking to a highly used image.
728                    ->useIndex( [ 'imagelinks' => 'PRIMARY' ] )
729                    ->caller( __METHOD__ )
730                    ->fetchResultSet();
731
732                foreach ( $res as $row ) {
733                    $protection = $protectedPages[$row->il_from];
734                    $this->protections[NS_FILE][$row->lt_title][] = $protection;
735                }
736
737                $this->resetVirtualDomain();
738            }
739        }
740    }
741
742    /**
743     * Get talk page IDs (if requested) and subject page IDs (if requested)
744     * and put them in $talkids and $subjectids
745     */
746    private function getTSIDs() {
747        $getTitles = $this->talkids = $this->subjectids = [];
748        $nsInfo = $this->namespaceInfo;
749
750        /** @var PageReference $page */
751        foreach ( $this->everything as $page ) {
752            if ( $nsInfo->isTalk( $page->getNamespace() ) ) {
753                if ( $this->fld_subjectid ) {
754                    $getTitles[] = $nsInfo->getSubjectPage( TitleValue::newFromPage( $page ) );
755                }
756            } elseif ( $this->fld_talkid ) {
757                $getTitles[] = $nsInfo->getTalkPage( TitleValue::newFromPage( $page ) );
758            }
759        }
760        if ( $getTitles === [] ) {
761            return;
762        }
763
764        $db = $this->getDB();
765
766        // Construct a custom WHERE clause that matches
767        // all titles in $getTitles
768        $lb = $this->linkBatchFactory->newLinkBatch( $getTitles );
769        $this->resetQueryParams();
770        $this->addTables( 'page' );
771        $this->addFields( [ 'page_title', 'page_namespace', 'page_id' ] );
772        $this->addWhere( $lb->constructSet( 'page', $db ) );
773        $res = $this->select( __METHOD__ );
774        foreach ( $res as $row ) {
775            if ( $nsInfo->isTalk( $row->page_namespace ) ) {
776                $this->talkids[$nsInfo->getSubject( $row->page_namespace )][$row->page_title] =
777                    (int)( $row->page_id );
778            } else {
779                $this->subjectids[$nsInfo->getTalk( $row->page_namespace )][$row->page_title] =
780                    (int)( $row->page_id );
781            }
782        }
783    }
784
785    private function getDisplayTitle() {
786        $this->displaytitles = [];
787
788        $pageIds = array_keys( $this->titles );
789
790        if ( $pageIds === [] ) {
791            return;
792        }
793
794        $this->resetQueryParams();
795        $this->addTables( 'page_props' );
796        $this->addFields( [ 'pp_page', 'pp_value' ] );
797        $this->addWhereFld( 'pp_page', $pageIds );
798        $this->addWhereFld( 'pp_propname', 'displaytitle' );
799        $res = $this->select( __METHOD__ );
800
801        foreach ( $res as $row ) {
802            $this->displaytitles[$row->pp_page] = $row->pp_value;
803        }
804    }
805
806    /**
807     * Fetch the set of extra link classes associated with links to the
808     * set of titles ("link colours"), as they would appear on the
809     * given context page.
810     * @param ?LinkTarget $context_title The page context in which link
811     *   colors are determined.
812     * @param bool $default_link_caption Whether to treat links as having
813     *   default captions for the purpose of determining link colors.
814     *   Default caption is defined as for {@see LinkRenderer::isDefaultLinkCaption()}.
815     */
816    private function getLinkClasses( ?LinkTarget $context_title = null, bool $default_link_caption = false ) {
817        if ( $this->titles === [] ) {
818            return;
819        }
820        // For compatibility with legacy GetLinkColours hook:
821        // $pagemap maps from page id to title (as prefixed db key)
822        // $classes maps from title (prefixed db key) to a space-separated
823        //   list of link classes ("link colours").
824        // The hook should not modify $pagemap, and should only append to
825        // $classes (being careful to maintain space separation).
826        $classes = [];
827        $pagemap = [];
828        foreach ( $this->titles as $pageId => $page ) {
829            $pdbk = $this->titleFormatter->getPrefixedDBkey( $page );
830            $pagemap[$pageId] = $pdbk;
831            $classes[$pdbk] = $this->linkRenderer->getLinkClasses( $page, $default_link_caption );
832        }
833        // legacy hook requires a real Title, not a LinkTarget
834        $context_title = $this->titleFactory->newFromLinkTarget(
835            $context_title ?? $this->titleFactory->newMainPage()
836        );
837        $this->getHookRunner()->onGetLinkColours(
838            $pagemap, $classes, $context_title
839        );
840
841        // This API class expects the class list to be:
842        //  (a) indexed by pageid, not title, and
843        //  (b) a proper array of strings (possibly zero-length),
844        //      not a single space-separated string (possibly the empty string)
845        $this->linkClasses = [];
846        foreach ( $this->titles as $pageId => $page ) {
847            $pdbk = $this->titleFormatter->getPrefixedDBkey( $page );
848            $this->linkClasses[$pageId] = preg_split(
849                '/\s+/', $classes[$pdbk] ?? '', -1, PREG_SPLIT_NO_EMPTY
850            );
851        }
852    }
853
854    private function getVariantTitles() {
855        if ( $this->titles === [] ) {
856            return;
857        }
858        $this->variantTitles = [];
859        foreach ( $this->titles as $pageId => $page ) {
860            $this->variantTitles[$pageId] = isset( $this->displaytitles[$pageId] )
861                ? $this->getAllVariants( $this->displaytitles[$pageId] )
862                : $this->getAllVariants( $this->titleFormatter->getText( $page ), $page->getNamespace() );
863        }
864    }
865
866    private function getAllVariants( string $text, int $ns = NS_MAIN ): array {
867        $result = [];
868        foreach ( $this->languageConverter->getVariants() as $variant ) {
869            $convertTitle = $this->languageConverter->autoConvert( $text, $variant );
870            if ( $ns !== NS_MAIN ) {
871                $convertNs = $this->languageConverter->convertNamespace( $ns, $variant );
872                $convertTitle = $convertNs . ':' . $convertTitle;
873            }
874            $result[$variant] = $convertTitle;
875        }
876        return $result;
877    }
878
879    /**
880     * Get information about watched status and put it in $this->watched
881     * and $this->notificationtimestamps
882     */
883    private function getWatchedInfo() {
884        $user = $this->getUser();
885
886        if ( !$user->isRegistered() || count( $this->everything ) == 0
887            || !$this->getAuthority()->isAllowed( 'viewmywatchlist' )
888        ) {
889            return;
890        }
891
892        $this->watched = [];
893        $this->watchlistExpiries = [];
894        $this->watchlistLabels = [];
895        $this->notificationtimestamps = [];
896
897        $items = $this->watchedItemStore->loadWatchedItemsBatch( $user, $this->everything );
898
899        foreach ( $items as $item ) {
900            $nsId = $item->getTarget()->getNamespace();
901            $dbKey = $item->getTarget()->getDBkey();
902
903            if ( $this->fld_watched ) {
904                $this->watched[$nsId][$dbKey] = true;
905
906                $expiry = $item->getExpiry( TS::ISO_8601 );
907                if ( $expiry ) {
908                    $this->watchlistExpiries[$nsId][$dbKey] = $expiry;
909                }
910            }
911
912            if ( $this->fld_watchlistlabels ) {
913                $labels = $item->getLabels();
914                if ( $labels ) {
915                    $labelData = [];
916                    foreach ( $labels as $label ) {
917                        $labelData[] = [
918                            'id' => $label->getId(),
919                            'name' => $label->getName(),
920                        ];
921                    }
922                    $this->watchlistLabels[$nsId][$dbKey] = $labelData;
923                }
924            }
925
926            if ( $this->fld_notificationtimestamp ) {
927                $this->notificationtimestamps[$nsId][$dbKey] = $item->getNotificationTimestamp();
928            }
929        }
930    }
931
932    /**
933     * Get the count of watchers and put it in $this->watchers
934     */
935    private function getWatcherInfo() {
936        if ( count( $this->everything ) == 0 ) {
937            return;
938        }
939
940        $canUnwatchedpages = $this->getAuthority()->isAllowed( 'unwatchedpages' );
941        $unwatchedPageThreshold =
942            $this->getConfig()->get( MainConfigNames::UnwatchedPageThreshold );
943        if ( !$canUnwatchedpages && !is_int( $unwatchedPageThreshold ) ) {
944            return;
945        }
946
947        $this->showZeroWatchers = $canUnwatchedpages;
948
949        $countOptions = [];
950        if ( !$canUnwatchedpages ) {
951            $countOptions['minimumWatchers'] = $unwatchedPageThreshold;
952        }
953
954        $this->watchers = $this->watchedItemStore->countWatchersMultiple(
955            $this->everything,
956            $countOptions
957        );
958    }
959
960    /**
961     * Get the count of watchers who have visited recent edits and put it in
962     * $this->visitingwatchers
963     *
964     * Based on InfoAction::pageCounts
965     */
966    private function getVisitingWatcherInfo() {
967        $config = $this->getConfig();
968        $db = $this->getDB();
969
970        $canUnwatchedpages = $this->getAuthority()->isAllowed( 'unwatchedpages' );
971        $unwatchedPageThreshold = $config->get( MainConfigNames::UnwatchedPageThreshold );
972        if ( !$canUnwatchedpages && !is_int( $unwatchedPageThreshold ) ) {
973            return;
974        }
975
976        $this->showZeroWatchers = $canUnwatchedpages;
977
978        $titlesWithThresholds = [];
979        if ( $this->titles ) {
980            $lb = $this->linkBatchFactory->newLinkBatch( $this->titles );
981
982            // Fetch last edit timestamps for pages
983            $this->resetQueryParams();
984            $this->addTables( [ 'page', 'revision' ] );
985            $this->addFields( [ 'page_namespace', 'page_title', 'rev_timestamp' ] );
986            $this->addWhere( [
987                'page_latest = rev_id',
988                $lb->constructSet( 'page', $db ),
989            ] );
990            $this->addOption( 'GROUP BY', [ 'page_namespace', 'page_title' ] );
991            $timestampRes = $this->select( __METHOD__ );
992
993            $age = $config->get( MainConfigNames::WatchersMaxAge );
994            $timestamps = [];
995            foreach ( $timestampRes as $row ) {
996                $revTimestamp = wfTimestamp( TS::UNIX, (int)$row->rev_timestamp );
997                $timestamps[$row->page_namespace][$row->page_title] = (int)$revTimestamp - $age;
998            }
999            $titlesWithThresholds = array_map(
1000                static function ( PageReference $target ) use ( $timestamps ) {
1001                    return [
1002                        $target, $timestamps[$target->getNamespace()][$target->getDBkey()]
1003                    ];
1004                },
1005                $this->titles
1006            );
1007        }
1008
1009        if ( $this->missing ) {
1010            $titlesWithThresholds = array_merge(
1011                $titlesWithThresholds,
1012                array_map(
1013                    static function ( PageReference $target ) {
1014                        return [ $target, null ];
1015                    },
1016                    $this->missing
1017                )
1018            );
1019        }
1020        $this->visitingwatchers = $this->watchedItemStore->countVisitingWatchersMultiple(
1021            $titlesWithThresholds,
1022            !$canUnwatchedpages ? $unwatchedPageThreshold : null
1023        );
1024    }
1025
1026    /** @inheritDoc */
1027    public function getCacheMode( $params ) {
1028        // Other props depend on something about the current user
1029        $publicProps = [
1030            'protection',
1031            'talkid',
1032            'subjectid',
1033            'associatedpage',
1034            'url',
1035            'preload',
1036            'displaytitle',
1037            'varianttitles',
1038        ];
1039        if ( array_diff( (array)$params['prop'], $publicProps ) ) {
1040            return 'private';
1041        }
1042
1043        // testactions also depends on the current user
1044        if ( $params['testactions'] ) {
1045            return 'private';
1046        }
1047
1048        return 'public';
1049    }
1050
1051    /** @inheritDoc */
1052    public function getAllowedParams() {
1053        return [
1054            'prop' => [
1055                ParamValidator::PARAM_ISMULTI => true,
1056                ParamValidator::PARAM_TYPE => [
1057                    'protection',
1058                    'talkid',
1059                    'watched', # private
1060                    'watchlistlabels', # private
1061                    'watchers', # private
1062                    'visitingwatchers', # private
1063                    'notificationtimestamp', # private
1064                    'subjectid',
1065                    'associatedpage',
1066                    'url',
1067                    'readable', # private
1068                    'preload',
1069                    'preloadcontent', # private: checks current user's permissions
1070                    'editintro', # private: checks current user's permissions
1071                    'displaytitle',
1072                    'varianttitles',
1073                    'linkclasses', # private: stub length (and possibly hook colors)
1074                    // If you add more properties here, please consider whether they
1075                    // need to be added to getCacheMode()
1076                ],
1077                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
1078                EnumDef::PARAM_DEPRECATED_VALUES => [
1079                    'readable' => true, // Since 1.32
1080                    'preload' => true, // Since 1.41
1081                ],
1082            ],
1083            'linkcontext' => [
1084                ParamValidator::PARAM_TYPE => 'title',
1085                ParamValidator::PARAM_DEFAULT => $this->titleFactory->newMainPage()->getPrefixedText(),
1086                TitleDef::PARAM_RETURN_OBJECT => true,
1087            ],
1088            'defaultlinkcaption' => [
1089                ParamValidator::PARAM_TYPE => 'boolean',
1090                ParamValidator::PARAM_DEFAULT => false,
1091            ],
1092            'testactions' => [
1093                ParamValidator::PARAM_TYPE => 'string',
1094                ParamValidator::PARAM_ISMULTI => true,
1095            ],
1096            'testactionsdetail' => [
1097                ParamValidator::PARAM_TYPE => [ 'boolean', 'full', 'quick' ],
1098                ParamValidator::PARAM_DEFAULT => 'boolean',
1099                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
1100            ],
1101            'testactionsautocreate' => false,
1102            'preloadcustom' => [
1103                // This should be a valid and existing page title, but we don't want to validate it here,
1104                // because it's usually someone else's fault. It could emit a warning in the future.
1105                ParamValidator::PARAM_TYPE => 'string',
1106                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'preloadcontentonly' ] ],
1107            ],
1108            'preloadparams' => [
1109                ParamValidator::PARAM_ISMULTI => true,
1110                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'preloadcontentonly' ] ],
1111            ],
1112            'preloadnewsection' => [
1113                ParamValidator::PARAM_TYPE => 'boolean',
1114                ParamValidator::PARAM_DEFAULT => false,
1115                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'preloadcontentonly' ] ],
1116            ],
1117            'editintrostyle' => [
1118                ParamValidator::PARAM_TYPE => [ 'lessframes', 'moreframes' ],
1119                ParamValidator::PARAM_DEFAULT => 'moreframes',
1120                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'editintroonly' ] ],
1121            ],
1122            'editintroskip' => [
1123                ParamValidator::PARAM_TYPE => 'string',
1124                ParamValidator::PARAM_ISMULTI => true,
1125                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'editintroonly' ] ],
1126            ],
1127            'editintrocustom' => [
1128                // This should be a valid and existing page title, but we don't want to validate it here,
1129                // because it's usually someone else's fault. It could emit a warning in the future.
1130                ParamValidator::PARAM_TYPE => 'string',
1131                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'editintroonly' ] ],
1132            ],
1133            'continue' => [
1134                ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
1135            ],
1136        ];
1137    }
1138
1139    /** @inheritDoc */
1140    protected function getExamplesMessages() {
1141        $title = Title::newMainPage()->getPrefixedText();
1142        $mp = rawurlencode( $title );
1143
1144        return [
1145            "action=query&prop=info&titles={$mp}"
1146                => 'apihelp-query+info-example-simple',
1147            "action=query&prop=info&inprop=protection&titles={$mp}"
1148                => 'apihelp-query+info-example-protection',
1149        ];
1150    }
1151
1152    /** @inheritDoc */
1153    public function getHelpUrls() {
1154        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Info';
1155    }
1156}
1157
1158/** @deprecated class alias since 1.43 */
1159class_alias( ApiQueryInfo::class, 'ApiQueryInfo' );