Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.24% covered (success)
90.24%
74 / 82
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediaLinksHandler
90.24% covered (success)
90.24%
74 / 82
58.33% covered (warning)
58.33%
7 / 12
19.34
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getPage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 run
80.95% covered (warning)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
4.11
 getDbResults
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 processDbResults
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 needsWriteAccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getParamSettings
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getETag
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getLastModified
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 hasRepresentation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMaxNumLinks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getResponseBodySchemaFileName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Rest\Handler;
4
5use MediaWiki\Deferred\LinksUpdate\ImageLinksTable;
6use MediaWiki\FileRepo\RepoGroup;
7use MediaWiki\Page\ExistingPageRecord;
8use MediaWiki\Page\PageLookup;
9use MediaWiki\Rest\Handler;
10use MediaWiki\Rest\LocalizedHttpException;
11use MediaWiki\Rest\Response;
12use MediaWiki\Rest\SimpleHandler;
13use Wikimedia\Message\MessageValue;
14use Wikimedia\ParamValidator\ParamValidator;
15use Wikimedia\Rdbms\IConnectionProvider;
16use Wikimedia\Timestamp\TimestampFormat as TS;
17
18/**
19 * Handler class for Core REST API endpoints that perform operations on revisions
20 */
21class MediaLinksHandler extends SimpleHandler {
22    use \MediaWiki\FileRepo\File\MediaFileTrait;
23
24    /** int The maximum number of media links to return */
25    private const MAX_NUM_LINKS = 100;
26
27    private IConnectionProvider $dbProvider;
28    private RepoGroup $repoGroup;
29    private PageLookup $pageLookup;
30
31    /**
32     * @var ExistingPageRecord|false|null
33     */
34    private $page = false;
35
36    public function __construct(
37        IConnectionProvider $dbProvider,
38        RepoGroup $repoGroup,
39        PageLookup $pageLookup
40    ) {
41        $this->dbProvider = $dbProvider;
42        $this->repoGroup = $repoGroup;
43        $this->pageLookup = $pageLookup;
44    }
45
46    private function getPage(): ?ExistingPageRecord {
47        if ( $this->page === false ) {
48            $this->page = $this->pageLookup->getExistingPageByText(
49                    $this->getValidatedParams()['title']
50                );
51        }
52        return $this->page;
53    }
54
55    /**
56     * @param string $title
57     * @return Response
58     * @throws LocalizedHttpException
59     */
60    public function run( $title ) {
61        $page = $this->getPage();
62        if ( !$page ) {
63            throw new LocalizedHttpException(
64                MessageValue::new( 'rest-nonexistent-title' )->plaintextParams( $title ),
65                404
66            );
67        }
68
69        if ( !$this->getAuthority()->authorizeRead( 'read', $page ) ) {
70            throw new LocalizedHttpException(
71                MessageValue::new( 'rest-permission-denied-title' )->plaintextParams( $title ),
72                403
73            );
74        }
75
76        // @todo: add continuation if too many links are found
77        $results = $this->getDbResults( $page->getId() );
78        if ( count( $results ) > $this->getMaxNumLinks() ) {
79            throw new LocalizedHttpException(
80                MessageValue::new( 'rest-media-too-many-links' )
81                    ->plaintextParams( $title )
82                    ->numParams( $this->getMaxNumLinks() ),
83                400
84            );
85        }
86        $response = $this->processDbResults( $results );
87        return $this->getResponseFactory()->createJson( $response );
88    }
89
90    /**
91     * @param int $pageId the id of the page to load media links for
92     * @return array the results
93     */
94    private function getDbResults( int $pageId ) {
95        $dbr = $this->dbProvider->getReplicaDatabase( ImageLinksTable::VIRTUAL_DOMAIN );
96
97        return $dbr->newSelectQueryBuilder()
98            ->select( 'lt_title' )
99            ->from( 'imagelinks' )
100            ->join( 'linktarget', null, 'il_target_id = lt_id' )
101            ->where( [ 'il_from' => $pageId, 'lt_namespace' => NS_FILE ] )
102            ->orderBy( 'lt_title' )
103            ->limit( $this->getMaxNumLinks() + 1 )
104            ->caller( __METHOD__ )
105            ->fetchFieldValues();
106    }
107
108    /**
109     * @param array $results database results, or an empty array if none
110     * @return array response data
111     */
112    private function processDbResults( $results ) {
113        // Using "private" here means an equivalent of the Action API's "anon-public-user-private"
114        // caching model would be necessary, if caching is ever added to this endpoint.
115        $performer = $this->getAuthority();
116        $findTitles = array_map( static function ( $title ) use ( $performer ) {
117            return [
118                'title' => $title,
119                'private' => $performer,
120            ];
121        }, $results );
122
123        $files = $this->repoGroup->findFiles( $findTitles );
124        [ $maxWidth, $maxHeight ] = self::getImageLimitsFromOption(
125            $this->getAuthority()->getUser(),
126            'imagesize'
127        );
128        $transforms = [
129            'preferred' => [
130                'maxWidth' => $maxWidth,
131                'maxHeight' => $maxHeight,
132            ]
133        ];
134        $response = [];
135        foreach ( $files as $file ) {
136            $response[] = $this->getFileInfo( $file, $performer, $transforms );
137        }
138
139        $response = [
140            'files' => $response
141        ];
142
143        return $response;
144    }
145
146    /** @inheritDoc */
147    public function needsWriteAccess() {
148        return false;
149    }
150
151    /** @inheritDoc */
152    public function getParamSettings() {
153        return [
154            'title' => [
155                self::PARAM_SOURCE => 'path',
156                ParamValidator::PARAM_TYPE => 'string',
157                ParamValidator::PARAM_REQUIRED => true,
158                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-media-links-title' ),
159            ],
160        ];
161    }
162
163    /**
164     * @return string|null
165     * @throws LocalizedHttpException
166     */
167    protected function getETag(): ?string {
168        $page = $this->getPage();
169        if ( !$page ) {
170            return null;
171        }
172
173        // XXX: use hash of the rendered HTML?
174        return '"' . $page->getLatest() . '@' . wfTimestamp( TS::MW, $page->getTouched() ) . '"';
175    }
176
177    /**
178     * @return string|null
179     * @throws LocalizedHttpException
180     */
181    protected function getLastModified(): ?string {
182        $page = $this->getPage();
183        return $page ? $page->getTouched() : null;
184    }
185
186    /**
187     * @return bool
188     */
189    protected function hasRepresentation() {
190        return (bool)$this->getPage();
191    }
192
193    /**
194     * For testing
195     *
196     * @unstable
197     */
198    protected function getMaxNumLinks(): int {
199        return self::MAX_NUM_LINKS;
200    }
201
202    public function getResponseBodySchemaFileName( string $method ): ?string {
203        return __DIR__ . '/Schema/MediaLinks.json';
204    }
205}