Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.00% covered (warning)
85.00%
119 / 140
80.00% covered (warning)
80.00%
16 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageContentHelper
85.00% covered (warning)
85.00%
119 / 140
80.00% covered (warning)
80.00%
16 / 20
54.46
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getTitleText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getPageIdentity
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getTargetRevision
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getRole
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContent
60.00% covered (warning)
60.00%
15 / 25
0.00% covered (danger)
0.00%
0 / 1
6.60
 isAccessible
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getETag
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getLastModified
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 hasContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 constructMetadata
75.00% covered (warning)
75.00%
18 / 24
0.00% covered (danger)
0.00%
0 / 1
2.06
 getParamSettings
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 setCacheControl
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 useDefaultSystemMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getDefaultSystemMessage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 checkAccessPermission
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 checkHasContent
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
5.27
 checkAccess
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Rest\Handler\Helper;
4
5use MediaWiki\Config\ServiceOptions;
6use MediaWiki\MainConfigNames;
7use MediaWiki\Message\Message;
8use MediaWiki\Page\ExistingPageRecord;
9use MediaWiki\Page\PageIdentity;
10use MediaWiki\Page\PageLookup;
11use MediaWiki\Permissions\Authority;
12use MediaWiki\Rest\Handler;
13use MediaWiki\Rest\LocalizedHttpException;
14use MediaWiki\Rest\ResponseInterface;
15use MediaWiki\Revision\MutableRevisionRecord;
16use MediaWiki\Revision\RevisionAccessException;
17use MediaWiki\Revision\RevisionLookup;
18use MediaWiki\Revision\RevisionRecord;
19use MediaWiki\Revision\SlotRecord;
20use MediaWiki\Revision\SuppressedDataException;
21use MediaWiki\Title\Title;
22use MediaWiki\Title\TitleFormatter;
23use TextContent;
24use Wikimedia\Message\MessageValue;
25use Wikimedia\ParamValidator\ParamValidator;
26use WikitextContent;
27
28/**
29 * @internal for use by core REST infrastructure
30 */
31class PageContentHelper {
32    private const MAX_AGE_200 = 5;
33
34    /**
35     * @internal
36     */
37    public const CONSTRUCTOR_OPTIONS = [
38        MainConfigNames::RightsUrl,
39        MainConfigNames::RightsText,
40    ];
41
42    /** @var ServiceOptions */
43    protected $options;
44
45    /** @var RevisionLookup */
46    protected $revisionLookup;
47
48    /** @var TitleFormatter */
49    protected $titleFormatter;
50
51    /** @var PageLookup */
52    protected $pageLookup;
53
54    /** @var Authority|null */
55    protected $authority = null;
56
57    /** @var string[] */
58    protected $parameters = null;
59
60    /** @var RevisionRecord|false|null */
61    protected $targetRevision = false;
62
63    /** @var ExistingPageRecord|false|null */
64    protected $pageRecord = false;
65
66    /** @var PageIdentity|false|null */
67    private $pageIdentity = false;
68
69    /**
70     * @param ServiceOptions $options
71     * @param RevisionLookup $revisionLookup
72     * @param TitleFormatter $titleFormatter
73     * @param PageLookup $pageLookup
74     */
75    public function __construct(
76        ServiceOptions $options,
77        RevisionLookup $revisionLookup,
78        TitleFormatter $titleFormatter,
79        PageLookup $pageLookup
80    ) {
81        $this->options = $options;
82        $this->revisionLookup = $revisionLookup;
83        $this->titleFormatter = $titleFormatter;
84        $this->pageLookup = $pageLookup;
85    }
86
87    /**
88     * @param Authority $authority
89     * @param string[] $parameters validated parameters
90     */
91    public function init( Authority $authority, array $parameters ) {
92        $this->authority = $authority;
93        $this->parameters = $parameters;
94    }
95
96    /**
97     * @return string|null title text or null if unable to retrieve title
98     */
99    public function getTitleText(): ?string {
100        return $this->parameters['title'] ?? null;
101    }
102
103    /**
104     * @return ExistingPageRecord|null
105     */
106    public function getPage(): ?ExistingPageRecord {
107        if ( $this->pageRecord === false ) {
108            $titleText = $this->getTitleText();
109            if ( $titleText === null ) {
110                return null;
111            }
112            $this->pageRecord = $this->pageLookup->getExistingPageByText( $titleText );
113        }
114        return $this->pageRecord;
115    }
116
117    public function getPageIdentity(): ?PageIdentity {
118        if ( $this->pageIdentity === false ) {
119            $this->pageIdentity = $this->getPage();
120        }
121
122        if ( $this->pageIdentity === null ) {
123            $titleText = $this->getTitleText();
124            if ( $titleText === null ) {
125                return null;
126            }
127            $this->pageIdentity = $this->pageLookup->getPageByText( $titleText );
128        }
129
130        return $this->pageIdentity;
131    }
132
133    /**
134     * Returns the target revision. No permission checks are applied.
135     *
136     * @return RevisionRecord|null latest revision or null if unable to retrieve revision
137     */
138    public function getTargetRevision(): ?RevisionRecord {
139        if ( $this->targetRevision === false ) {
140            $page = $this->getPage();
141            if ( $page ) {
142                $this->targetRevision = $this->revisionLookup->getRevisionByTitle( $page );
143            } else {
144                $this->targetRevision = null;
145            }
146        }
147        return $this->targetRevision;
148    }
149
150    // Default to main slot
151    public function getRole(): string {
152        return SlotRecord::MAIN;
153    }
154
155    /**
156     * @return TextContent
157     * @throws LocalizedHttpException slot content is not TextContent or RevisionRecord/Slot is inaccessible
158     */
159    public function getContent(): TextContent {
160        $revision = $this->getTargetRevision();
161
162        if ( !$revision ) {
163            $titleText = $this->getTitleText() ?? '';
164            throw new LocalizedHttpException(
165                MessageValue::new( 'rest-no-revision' )->plaintextParams( $titleText ),
166                404
167            );
168        }
169
170        $slotRole = $this->getRole();
171
172        try {
173            $content = $revision
174                ->getSlot( $slotRole, RevisionRecord::FOR_THIS_USER, $this->authority )
175                ->getContent()
176                ->convert( CONTENT_MODEL_TEXT );
177            if ( !( $content instanceof TextContent ) ) {
178                throw new LocalizedHttpException( MessageValue::new( 'rest-page-source-type-error' ), 400 );
179            }
180        } catch ( SuppressedDataException $e ) {
181            throw new LocalizedHttpException(
182                MessageValue::new( 'rest-permission-denied-revision' )->numParams( $revision->getId() ),
183                403
184            );
185        } catch ( RevisionAccessException $e ) {
186            throw new LocalizedHttpException(
187                MessageValue::new( 'rest-nonexistent-revision' )->numParams( $revision->getId() ),
188                404
189            );
190        }
191        return $content;
192    }
193
194    /**
195     * @return bool
196     */
197    public function isAccessible(): bool {
198        $page = $this->getPageIdentity();
199        return $page && $this->authority->probablyCan( 'read', $page );
200    }
201
202    /**
203     * Returns an ETag representing a page's source. The ETag assumes a page's source has changed
204     * if the latest revision of a page has been made private, un-readable for another reason,
205     * or a newer revision exists.
206     * @return string|null
207     */
208    public function getETag(): ?string {
209        $revision = $this->getTargetRevision();
210        $revId = $revision ? $revision->getId() : 'e0';
211
212        $isAccessible = $this->isAccessible();
213        $accessibleTag = $isAccessible ? 'a1' : 'a0';
214
215        $revisionTag = $revId . $accessibleTag;
216        return '"' . sha1( $revisionTag ) . '"';
217    }
218
219    /**
220     * @return string|null
221     */
222    public function getLastModified(): ?string {
223        if ( !$this->isAccessible() ) {
224            return null;
225        }
226
227        $revision = $this->getTargetRevision();
228        if ( $revision ) {
229            return $revision->getTimestamp();
230        }
231        return null;
232    }
233
234    /**
235     * Checks whether content exists. Permission checks are not considered.
236     *
237     * @return bool
238     */
239    public function hasContent(): bool {
240        return $this->useDefaultSystemMessage() || (bool)$this->getPage();
241    }
242
243    /**
244     * @return array
245     */
246    public function constructMetadata(): array {
247        if ( $this->useDefaultSystemMessage() ) {
248            $title = Title::newFromText( $this->getTitleText() );
249            $content = new WikitextContent( $title->getDefaultMessageText() );
250            $revision = new MutableRevisionRecord( $title );
251            $revision->setPageId( 0 );
252            $revision->setId( 0 );
253            $revision->setContent( SlotRecord::MAIN, $content );
254        } else {
255            $revision = $this->getTargetRevision();
256        }
257
258        $page = $revision->getPage();
259        return [
260            'id' => $page->getId(),
261            'key' => $this->titleFormatter->getPrefixedDBkey( $page ),
262            'title' => $this->titleFormatter->getPrefixedText( $page ),
263            'latest' => [
264                'id' => $revision->getId(),
265                'timestamp' => wfTimestampOrNull( TS_ISO_8601, $revision->getTimestamp() )
266            ],
267            'content_model' => $revision->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )
268                ->getModel(),
269            'license' => [
270                'url' => $this->options->get( MainConfigNames::RightsUrl ),
271                'title' => $this->options->get( MainConfigNames::RightsText )
272            ],
273        ];
274    }
275
276    /**
277     * @return array[]
278     */
279    public function getParamSettings(): array {
280        return [
281            'title' => [
282                Handler::PARAM_SOURCE => 'path',
283                ParamValidator::PARAM_TYPE => 'string',
284                ParamValidator::PARAM_REQUIRED => true,
285            ],
286            'redirect' => [
287                Handler::PARAM_SOURCE => 'query',
288                ParamValidator::PARAM_TYPE => [ 'no' ],
289                ParamValidator::PARAM_REQUIRED => false,
290            ]
291        ];
292    }
293
294    /**
295     * Sets the 'Cache-Control' header no more then provided $expiry.
296     * @param ResponseInterface $response
297     * @param int|null $expiry
298     */
299    public function setCacheControl( ResponseInterface $response, int $expiry = null ) {
300        if ( $expiry === null ) {
301            $maxAge = self::MAX_AGE_200;
302        } else {
303            $maxAge = min( self::MAX_AGE_200, $expiry );
304        }
305        $response->setHeader(
306            'Cache-Control',
307            'max-age=' . $maxAge
308        );
309    }
310
311    /**
312     * If the page is a system message page. When the content gets
313     * overridden to create an actual page, this method returns false.
314     *
315     * @return bool
316     */
317    public function useDefaultSystemMessage(): bool {
318        return $this->getDefaultSystemMessage() !== null && $this->getPage() === null;
319    }
320
321    /**
322     * @return Message|null
323     */
324    public function getDefaultSystemMessage(): ?Message {
325        $title = Title::newFromText( $this->getTitleText() );
326
327        return $title ? $title->getDefaultSystemMessage() : null;
328    }
329
330    /**
331     * @throws LocalizedHttpException if access is not allowed
332     */
333    public function checkAccessPermission() {
334        $titleText = $this->getTitleText() ?? '';
335
336        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Validated by hasContent
337        if ( !$this->isAccessible() || !$this->authority->authorizeRead( 'read', $this->getPageIdentity() ) ) {
338            throw new LocalizedHttpException(
339                MessageValue::new( 'rest-permission-denied-title' )->plaintextParams( $titleText ),
340                403
341            );
342        }
343    }
344
345    /**
346     * @throws LocalizedHttpException if no content is available
347     */
348    public function checkHasContent() {
349        $titleText = $this->getTitleText() ?? '';
350
351        $page = $this->getPageIdentity();
352        if ( !$page ) {
353            throw new LocalizedHttpException(
354                MessageValue::new( 'rest-invalid-title' )->plaintextParams( $titleText ),
355                404
356            );
357        }
358
359        if ( !$this->hasContent() ) {
360            // needs to check if it's possibly a variant title
361            throw new LocalizedHttpException(
362                MessageValue::new( 'rest-nonexistent-title' )->plaintextParams( $titleText ),
363                404
364            );
365        }
366
367        $revision = $this->getTargetRevision();
368        if ( !$revision && !$this->useDefaultSystemMessage() ) {
369            throw new LocalizedHttpException(
370                MessageValue::new( 'rest-no-revision' )->plaintextParams( $titleText ),
371                404
372            );
373        }
374    }
375
376    /**
377     * @throws LocalizedHttpException if the content is not accessible
378     */
379    public function checkAccess() {
380        $this->checkHasContent(); // Status 404: Not Found
381        $this->checkAccessPermission(); // Status 403: Forbidden
382    }
383
384}