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