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