Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
25.53% covered (danger)
25.53%
24 / 94
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageImages
25.53% covered (danger)
25.53%
24 / 94
40.00% covered (danger)
40.00%
4 / 10
427.86
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onRegistration
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getPropName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getPropNames
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getImage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 fetchPageImage
23.81% covered (danger)
23.81%
5 / 21
0.00% covered (danger)
0.00%
0 / 1
16.06
 onInfoAction
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 onApiOpenSearchSuggest
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getImages
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 onBeforePageDisplay
53.33% covered (warning)
53.33%
8 / 15
0.00% covered (danger)
0.00%
0 / 1
11.98
1<?php
2
3namespace PageImages;
4
5use MediaWiki\Actions\Hook\InfoActionHook;
6use MediaWiki\Api\ApiBase;
7use MediaWiki\Api\ApiMain;
8use MediaWiki\Api\Hook\ApiOpenSearchSuggestHook;
9use MediaWiki\Config\Config;
10use MediaWiki\Context\IContextSource;
11use MediaWiki\FileRepo\File\File;
12use MediaWiki\FileRepo\RepoGroup;
13use MediaWiki\MainConfigNames;
14use MediaWiki\Output\Hook\BeforePageDisplayHook;
15use MediaWiki\Output\OutputPage;
16use MediaWiki\Page\CacheKeyHelper;
17use MediaWiki\Request\FauxRequest;
18use MediaWiki\Settings\SettingsBuilder;
19use MediaWiki\Skin\Skin;
20use MediaWiki\Title\Title;
21use MediaWiki\User\Options\UserOptionsLookup;
22use MediaWiki\Utils\UrlUtils;
23use Wikimedia\ObjectCache\MapCacheLRU;
24use Wikimedia\Rdbms\IConnectionProvider;
25
26/**
27 * @license WTFPL
28 * @author Max Semenik
29 * @author Brad Jorsch
30 * @author Thiemo Kreuz
31 */
32class PageImages implements
33    ApiOpenSearchSuggestHook,
34    BeforePageDisplayHook,
35    InfoActionHook
36{
37    /**
38     * @const value for free images
39     */
40    public const LICENSE_FREE = 'free';
41
42    /**
43     * @const value for images with any type of license
44     */
45    public const LICENSE_ANY = 'any';
46
47    /**
48     * Page property used to store the best page image information.
49     * If the best image is the same as the best image with free license,
50     * then nothing is stored under this property.
51     * Note changing this value is not advised as it will invalidate all
52     * existing page property names on a production instance
53     * and cause them to be regenerated.
54     * @see self::PROP_NAME_FREE
55     */
56    public const PROP_NAME = 'page_image';
57
58    /**
59     * Page property used to store the best free page image information
60     * Note changing this value is not advised as it will invalidate all
61     * existing page property names on a production instance
62     * and cause them to be regenerated.
63     */
64    public const PROP_NAME_FREE = 'page_image_free';
65
66    private static ?MapCacheLRU $cache = null;
67
68    public function __construct(
69        private readonly Config $config,
70        private readonly IConnectionProvider $dbProvider,
71        private readonly RepoGroup $repoGroup,
72        private readonly UrlUtils $urlUtils,
73        private readonly UserOptionsLookup $userOptionsLookup,
74    ) {
75    }
76
77    /**
78     * Set dynamic default value for PageImagesNamespaces configuration option.
79     *
80     * @param array $extInfo An array containing information about the extension
81     * @param SettingsBuilder $settings
82     */
83    public static function onRegistration(
84        array $extInfo,
85        SettingsBuilder $settings
86    ) {
87        if ( $settings->getConfig()->get( 'PageImagesNamespaces' ) === false ) {
88            $settings->overrideConfigValue(
89                'PageImagesNamespaces',
90                $settings->getConfig()->get( MainConfigNames::ContentNamespaces )
91            );
92        }
93    }
94
95    /**
96     * Get property name used in page_props table. When a page image
97     * is stored it will be stored under this property name on the corresponding
98     * article.
99     *
100     * @param bool $isFree Whether the image is a free-license image
101     * @return string
102     */
103    public static function getPropName( bool $isFree ): string {
104        return $isFree ? self::PROP_NAME_FREE : self::PROP_NAME;
105    }
106
107    /**
108     * Get property names used in page_props table
109     *
110     * If the license is free, then only the free property name will be returned,
111     * otherwise both free and non-free property names will be returned. That's
112     * because we save the image name only once if it's free and the best image.
113     *
114     * @param string $license either LICENSE_FREE or LICENSE_ANY,
115     * specifying whether to return the non-free property name or not
116     * @return string|array
117     */
118    public static function getPropNames( string $license ) {
119        if ( $license === self::LICENSE_FREE ) {
120            return self::getPropName( true );
121        }
122        return [ self::getPropName( true ), self::getPropName( false ) ];
123    }
124
125    /**
126     * Return page image for a given title
127     *
128     * @param Title $title Title to get page image for
129     * @return File|null
130     */
131    public function getImage( Title $title ): ?File {
132        self::$cache ??= new MapCacheLRU( 100 );
133
134        $file = self::$cache->getWithSetCallback(
135            CacheKeyHelper::getKeyForPage( $title ),
136            fn () => $this->fetchPageImage( $title )
137        );
138
139        // Cast false to null
140        return $file ?: null;
141    }
142
143    /**
144     * @param Title $title Title to get page image for
145     * @return File|null|false
146     */
147    private function fetchPageImage( Title $title ) {
148        if ( !$title->canExist() ) {
149            // Optimization: Do not query for special pages or other titles never in the database
150            return false;
151        }
152
153        if ( $title->inNamespace( NS_FILE ) ) {
154            return $this->repoGroup->findFile( $title );
155        }
156
157        $pageId = $title->getArticleID();
158        if ( !$pageId ) {
159            // No page id to select from
160            // Allow caching, cast null to false later
161            return null;
162        }
163
164        $dbr = $this->dbProvider->getReplicaDatabase();
165        $fileName = $dbr->newSelectQueryBuilder()
166            ->select( 'pp_value' )
167            ->from( 'page_props' )
168            ->where( [
169                'pp_page' => $pageId,
170                'pp_propname' => [ self::PROP_NAME, self::PROP_NAME_FREE ]
171            ] )
172            ->orderBy( 'pp_propname' )
173            ->caller( __METHOD__ )
174            ->fetchField();
175        if ( !$fileName ) {
176            // Return not found without caching.
177            return false;
178        }
179
180        return $this->repoGroup->findFile( $fileName );
181    }
182
183    /**
184     * InfoAction hook handler, adds the page image to the info=action page
185     *
186     * @see https://www.mediawiki.org/wiki/Manual:Hooks/InfoAction
187     *
188     * @param IContextSource $context Context, used to extract the title of the page
189     * @param array[] &$pageInfo Auxillary information about the page.
190     */
191    public function onInfoAction( $context, &$pageInfo ) {
192        $imageFile = $this->getImage( $context->getTitle() );
193        if ( !$imageFile ) {
194            // The page has no image
195            return;
196        }
197
198        $thumbSetting = $this->userOptionsLookup->getOption( $context->getUser(), 'thumbsize' );
199        $thumbSize = $this->config->get( MainConfigNames::ThumbLimits )[$thumbSetting];
200
201        $thumb = $imageFile->transform( [ 'width' => $thumbSize ] );
202        if ( !$thumb ) {
203            return;
204        }
205        $imageHtml = $thumb->toHtml(
206            [
207                'alt' => $imageFile->getTitle()->getText(),
208                'desc-link' => true,
209            ]
210        );
211
212        $pageInfo['header-basic'][] = [
213            $context->msg( 'pageimages-info-label' ),
214            $imageHtml
215        ];
216    }
217
218    /**
219     * ApiOpenSearchSuggest hook handler, enhances ApiOpenSearch results with this extension's data
220     *
221     * @param array[] &$results Array of results to add page images too
222     */
223    public function onApiOpenSearchSuggest( &$results ) {
224        if ( !$this->config->get( 'PageImagesExpandOpenSearchXml' ) || !count( $results ) ) {
225            return;
226        }
227
228        $pageIds = array_keys( $results );
229        $data = self::getImages( $pageIds, 50 );
230        foreach ( $pageIds as $id ) {
231            $results[$id]['image'] = $data[$id]['thumbnail'] ?? null;
232        }
233    }
234
235    /**
236     * Returns image information for pages with given ids
237     *
238     * @param int[] $pageIds
239     * @param int $size
240     *
241     * @return array[]
242     */
243    public static function getImages( array $pageIds, int $size = 0 ): array {
244        $ret = [];
245        foreach ( array_chunk( $pageIds, ApiBase::LIMIT_SML1 ) as $chunk ) {
246            $request = [
247                'action' => 'query',
248                'prop' => 'pageimages',
249                'piprop' => 'name',
250                'pageids' => implode( '|', $chunk ),
251                'pilimit' => 'max',
252            ];
253
254            if ( $size ) {
255                $request['piprop'] = 'thumbnail';
256                $request['pithumbsize'] = $size;
257            }
258
259            $api = new ApiMain( new FauxRequest( $request ) );
260            $api->execute();
261
262            $ret += (array)$api->getResult()->getResultData(
263                [ 'query', 'pages' ], [ 'Strip' => 'base' ]
264            );
265        }
266        return $ret;
267    }
268
269    /**
270     * @param OutputPage $out The page being output.
271     * @param Skin $skin Skin object used to generate the page. Ignored
272     */
273    public function onBeforePageDisplay( $out, $skin ): void {
274        if ( !$out->getConfig()->get( 'PageImagesOpenGraph' ) ) {
275            return;
276        }
277        $imageFile = $this->getImage( $out->getContext()->getTitle() );
278        if ( !$imageFile ) {
279            $fallback = $out->getConfig()->get( 'PageImagesOpenGraphFallbackImage' );
280            if ( $fallback ) {
281                $out->addMeta( 'og:image', $this->urlUtils->expand( $fallback, PROTO_CANONICAL ) ?? '' );
282            }
283            return;
284        }
285
286        // Open Graph protocol -- https://ogp.me/
287        // See https://developers.facebook.com/docs/sharing/best-practices?locale=en_US#images
288        // T295521: Updated in 2025, WhatsApp expects images >300px, but <600KB
289        // See https://developers.facebook.com/docs/whatsapp/link-previews/
290        $thumb = $imageFile->transform( [ 'width' => 1200, 'height' => 1200 ] );
291        if ( $thumb && $thumb->getUrl() ) {
292            $url = $this->urlUtils->expand( $thumb->getUrl(), PROTO_CANONICAL );
293            if ( $url ) {
294                $out->addMeta( 'og:image', $url );
295                $out->addMeta( 'og:image:width', (string)$thumb->getWidth() );
296                $out->addMeta( 'og:image:height', (string)$thumb->getHeight() );
297            }
298        }
299    }
300
301}