Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.51% covered (warning)
71.51%
128 / 179
78.26% covered (warning)
78.26%
18 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageContentHelper
71.51% covered (warning)
71.51%
128 / 179
78.26% covered (warning)
78.26%
18 / 23
114.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
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
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 constructRestbaseCompatibleMetadata
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
12
 getParamSettings
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 getRedirectsAllowed
100.00% covered (success)
100.00%
1 / 1
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
 getRevisionRecordForMetadata
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
3.69
1<?php
2
3namespace MediaWiki\Rest\Handler\Helper;
4
5use MediaWiki\ChangeTags\ChangeTagsStore;
6use MediaWiki\Config\ServiceOptions;
7use MediaWiki\Content\TextContent;
8use MediaWiki\Content\WikitextContent;
9use MediaWiki\MainConfigNames;
10use MediaWiki\Message\Message;
11use MediaWiki\Page\ExistingPageRecord;
12use MediaWiki\Page\PageIdentity;
13use MediaWiki\Page\PageLookup;
14use MediaWiki\Permissions\Authority;
15use MediaWiki\Rest\Handler;
16use MediaWiki\Rest\LocalizedHttpException;
17use MediaWiki\Rest\ResponseInterface;
18use MediaWiki\Revision\MutableRevisionRecord;
19use MediaWiki\Revision\RevisionAccessException;
20use MediaWiki\Revision\RevisionLookup;
21use MediaWiki\Revision\RevisionRecord;
22use MediaWiki\Revision\SlotRecord;
23use MediaWiki\Revision\SuppressedDataException;
24use MediaWiki\Title\Title;
25use MediaWiki\Title\TitleFactory;
26use MediaWiki\Title\TitleFormatter;
27use Wikimedia\Message\MessageValue;
28use Wikimedia\ParamValidator\ParamValidator;
29use Wikimedia\Rdbms\IConnectionProvider;
30
31/**
32 * @internal for use by core REST infrastructure
33 */
34class PageContentHelper {
35
36    /**
37     * The maximum cache duration for page content.
38     *
39     * If this is set to a value higher than about 60 seconds, active purging
40     * will have to be employed to make sure clients do not receive overly stale
41     * content. This is especially important to avoid distributing vandalized
42     * content for too long.
43     *
44     * Active purging can be enabled by adding the relevant URLs to
45     * HTMLCacheUpdater. See T365630 for more discussion.
46     */
47    private const MAX_AGE_200 = 5;
48
49    /**
50     * @internal
51     */
52    public const CONSTRUCTOR_OPTIONS = [
53        MainConfigNames::RightsUrl,
54        MainConfigNames::RightsText,
55    ];
56
57    protected ServiceOptions $options;
58    protected RevisionLookup $revisionLookup;
59    protected TitleFormatter $titleFormatter;
60    protected PageLookup $pageLookup;
61    private TitleFactory $titleFactory;
62    private IConnectionProvider $dbProvider;
63    private ChangeTagsStore $changeTagStore;
64
65    /** @var Authority|null */
66    protected $authority = null;
67
68    /** @var string[] */
69    protected $parameters = null;
70
71    /** @var RevisionRecord|false|null */
72    protected $targetRevision = false;
73
74    /** @var ExistingPageRecord|false|null */
75    protected $pageRecord = false;
76
77    /** @var PageIdentity|false|null */
78    private $pageIdentity = false;
79
80    public function __construct(
81        ServiceOptions $options,
82        RevisionLookup $revisionLookup,
83        TitleFormatter $titleFormatter,
84        PageLookup $pageLookup,
85        TitleFactory $titleFactory,
86        IConnectionProvider $dbProvider,
87        ChangeTagsStore $changeTagStore
88    ) {
89        $this->options = $options;
90        $this->revisionLookup = $revisionLookup;
91        $this->titleFormatter = $titleFormatter;
92        $this->pageLookup = $pageLookup;
93        $this->titleFactory = $titleFactory;
94        $this->dbProvider = $dbProvider;
95        $this->changeTagStore = $changeTagStore;
96    }
97
98    /**
99     * @param Authority $authority
100     * @param string[] $parameters validated parameters
101     */
102    public function init( Authority $authority, array $parameters ) {
103        $this->authority = $authority;
104        $this->parameters = $parameters;
105    }
106
107    /**
108     * @return string|null title text or null if unable to retrieve title
109     */
110    public function getTitleText(): ?string {
111        return $this->parameters['title'] ?? null;
112    }
113
114    /**
115     * @return ExistingPageRecord|null
116     */
117    public function getPage(): ?ExistingPageRecord {
118        if ( $this->pageRecord === false ) {
119            $titleText = $this->getTitleText();
120            if ( $titleText === null ) {
121                return null;
122            }
123            $this->pageRecord = $this->pageLookup->getExistingPageByText( $titleText );
124        }
125        return $this->pageRecord;
126    }
127
128    public function getPageIdentity(): ?PageIdentity {
129        if ( $this->pageIdentity === false ) {
130            $this->pageIdentity = $this->getPage();
131        }
132
133        if ( $this->pageIdentity === null ) {
134            $titleText = $this->getTitleText();
135            if ( $titleText === null ) {
136                return null;
137            }
138            $this->pageIdentity = $this->pageLookup->getPageByText( $titleText );
139        }
140
141        return $this->pageIdentity;
142    }
143
144    /**
145     * Returns the target revision. No permission checks are applied.
146     *
147     * @return RevisionRecord|null latest revision or null if unable to retrieve revision
148     */
149    public function getTargetRevision(): ?RevisionRecord {
150        if ( $this->targetRevision === false ) {
151            $page = $this->getPage();
152            if ( $page ) {
153                $this->targetRevision = $this->revisionLookup->getRevisionByTitle( $page );
154            } else {
155                $this->targetRevision = null;
156            }
157        }
158        return $this->targetRevision;
159    }
160
161    // Default to main slot
162    public function getRole(): string {
163        return SlotRecord::MAIN;
164    }
165
166    /**
167     * @return TextContent
168     * @throws LocalizedHttpException slot content is not TextContent or RevisionRecord/Slot is inaccessible
169     */
170    public function getContent(): TextContent {
171        $revision = $this->getTargetRevision();
172
173        if ( !$revision ) {
174            $titleText = $this->getTitleText() ?? '';
175            throw new LocalizedHttpException(
176                MessageValue::new( 'rest-no-revision' )->plaintextParams( $titleText ),
177                404
178            );
179        }
180
181        $slotRole = $this->getRole();
182
183        try {
184            $content = $revision
185                ->getSlot( $slotRole, RevisionRecord::FOR_THIS_USER, $this->authority )
186                ->getContent()
187                ->convert( CONTENT_MODEL_TEXT );
188            if ( !( $content instanceof TextContent ) ) {
189                throw new LocalizedHttpException( MessageValue::new( 'rest-page-source-type-error' ), 400 );
190            }
191        } catch ( SuppressedDataException $e ) {
192            throw new LocalizedHttpException(
193                MessageValue::new( 'rest-permission-denied-revision' )->numParams( $revision->getId() ),
194                403
195            );
196        } catch ( RevisionAccessException $e ) {
197            throw new LocalizedHttpException(
198                MessageValue::new( 'rest-nonexistent-revision' )->numParams( $revision->getId() ),
199                404
200            );
201        }
202        return $content;
203    }
204
205    /**
206     * @return bool
207     */
208    public function isAccessible(): bool {
209        $page = $this->getPageIdentity();
210        return $page && $this->authority->probablyCan( 'read', $page );
211    }
212
213    /**
214     * Returns an ETag representing a page's source. The ETag assumes a page's source has changed
215     * if the latest revision of a page has been made private, un-readable for another reason,
216     * or a newer revision exists.
217     * @return string|null
218     */
219    public function getETag(): ?string {
220        $revision = $this->getTargetRevision();
221        $revId = $revision ? $revision->getId() : 'e0';
222
223        $isAccessible = $this->isAccessible();
224        $accessibleTag = $isAccessible ? 'a1' : 'a0';
225
226        $revisionTag = $revId . $accessibleTag;
227        return '"' . sha1( $revisionTag ) . '"';
228    }
229
230    /**
231     * @return string|null
232     */
233    public function getLastModified(): ?string {
234        if ( !$this->isAccessible() ) {
235            return null;
236        }
237
238        $revision = $this->getTargetRevision();
239        if ( $revision ) {
240            return $revision->getTimestamp();
241        }
242        return null;
243    }
244
245    /**
246     * Checks whether content exists. Permission checks are not considered.
247     *
248     * @return bool
249     */
250    public function hasContent(): bool {
251        return $this->useDefaultSystemMessage() || (bool)$this->getPage();
252    }
253
254    /**
255     * @return array
256     */
257    public function constructMetadata(): array {
258        $revision = $this->getRevisionRecordForMetadata();
259
260        $page = $revision->getPage();
261        return [
262            'id' => $page->getId(),
263            'key' => $this->titleFormatter->getPrefixedDBkey( $page ),
264            'title' => $this->titleFormatter->getPrefixedText( $page ),
265            'latest' => [
266                'id' => $revision->getId(),
267                'timestamp' => wfTimestampOrNull( TS_ISO_8601, $revision->getTimestamp() )
268            ],
269            'content_model' => $revision->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )
270                ->getModel(),
271            'license' => [
272                'url' => $this->options->get( MainConfigNames::RightsUrl ),
273                'title' => $this->options->get( MainConfigNames::RightsText )
274            ],
275        ];
276    }
277
278    /**
279     * @return array
280     */
281    public function constructRestbaseCompatibleMetadata(): array {
282        $revision = $this->getRevisionRecordForMetadata();
283
284        $page = $revision->getPage();
285        $title = $this->titleFactory->newFromPageIdentity( $page );
286
287        $tags = $this->changeTagStore->getTags(
288            $this->dbProvider->getReplicaDatabase(),
289            null, $revision->getId(), null
290        );
291
292        $restrictions = [];
293
294        if ( $revision->isDeleted( RevisionRecord::DELETED_COMMENT ) ) {
295            $restrictions[] = 'commenthidden';
296        }
297
298        if ( $revision->isDeleted( RevisionRecord::DELETED_USER ) ) {
299            $restrictions[] = 'userhidden';
300        }
301
302        return [
303            'title' => $title->getPrefixedDBkey(),
304            'page_id' => $page->getId(),
305            'rev' => $revision->getId(),
306
307            // We could look up the tid from a ParserOutput, but it's expensive,
308            // and the tid can't be used for anything anymore anyway.
309            // Don't use an empty string though, that may break routing when the
310            // value is used as a path parameter.
311            'tid' => 'DUMMY',
312
313            'namespace' => $page->getNamespace(),
314            'user_id' => $revision->getUser( RevisionRecord::RAW )->getId(),
315            'user_text' => $revision->getUser( RevisionRecord::FOR_PUBLIC )->getName(),
316            'timestamp' => wfTimestampOrNull( TS_ISO_8601, $revision->getTimestamp() ),
317            'comment' => $revision->getComment()->text,
318            'tags' => $tags,
319            'restrictions' => $restrictions,
320            'page_language' => $title->getPageLanguage()->getCode(),
321            'redirect' => $title->isRedirect()
322        ];
323    }
324
325    /**
326     * @return array[]
327     */
328    public function getParamSettings(): array {
329        return [
330            'title' => [
331                Handler::PARAM_SOURCE => 'path',
332                ParamValidator::PARAM_TYPE => 'string',
333                ParamValidator::PARAM_REQUIRED => true,
334                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-page-content-title' ),
335            ],
336            'redirect' => [
337                Handler::PARAM_SOURCE => 'query',
338                ParamValidator::PARAM_TYPE => 'boolean',
339                ParamValidator::PARAM_REQUIRED => false,
340                ParamValidator::PARAM_DEFAULT => true,
341                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-page-content-redirect' ),
342            ]
343        ];
344    }
345
346    /**
347     * Whether the handler is allowed to follow redirects, according to the
348     * request parameters.
349     *
350     * Handlers that can follow wiki redirects can use this to give clients
351     * control over the redirect handling behavior.
352     *
353     * @return bool
354     */
355    public function getRedirectsAllowed(): bool {
356        return $this->parameters['redirect'] ?? true;
357    }
358
359    /**
360     * Sets the 'Cache-Control' header no more then provided $expiry.
361     * @param ResponseInterface $response
362     * @param int|null $expiry
363     */
364    public function setCacheControl( ResponseInterface $response, ?int $expiry = null ) {
365        if ( $expiry === null ) {
366            $maxAge = self::MAX_AGE_200;
367        } else {
368            $maxAge = min( self::MAX_AGE_200, $expiry );
369        }
370        $response->setHeader(
371            'Cache-Control',
372            'max-age=' . $maxAge
373        );
374    }
375
376    /**
377     * If the page is a system message page. When the content gets
378     * overridden to create an actual page, this method returns false.
379     *
380     * @return bool
381     */
382    public function useDefaultSystemMessage(): bool {
383        return $this->getDefaultSystemMessage() !== null && $this->getPage() === null;
384    }
385
386    /**
387     * @return Message|null
388     */
389    public function getDefaultSystemMessage(): ?Message {
390        $title = Title::newFromText( $this->getTitleText() );
391
392        return $title ? $title->getDefaultSystemMessage() : null;
393    }
394
395    /**
396     * @throws LocalizedHttpException if access is not allowed
397     */
398    public function checkAccessPermission() {
399        $titleText = $this->getTitleText() ?? '';
400
401        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Validated by hasContent
402        if ( !$this->isAccessible() || !$this->authority->authorizeRead( 'read', $this->getPageIdentity() ) ) {
403            throw new LocalizedHttpException(
404                MessageValue::new( 'rest-permission-denied-title' )->plaintextParams( $titleText ),
405                403
406            );
407        }
408    }
409
410    /**
411     * @throws LocalizedHttpException if no content is available
412     */
413    public function checkHasContent() {
414        $titleText = $this->getTitleText() ?? '';
415
416        $page = $this->getPageIdentity();
417        if ( !$page ) {
418            throw new LocalizedHttpException(
419                MessageValue::new( 'rest-invalid-title' )->plaintextParams( $titleText ),
420                404
421            );
422        }
423
424        if ( !$this->hasContent() ) {
425            // needs to check if it's possibly a variant title
426            throw new LocalizedHttpException(
427                MessageValue::new( 'rest-nonexistent-title' )->plaintextParams( $titleText ),
428                404
429            );
430        }
431
432        $revision = $this->getTargetRevision();
433        if ( !$revision && !$this->useDefaultSystemMessage() ) {
434            throw new LocalizedHttpException(
435                MessageValue::new( 'rest-no-revision' )->plaintextParams( $titleText ),
436                404
437            );
438        }
439    }
440
441    /**
442     * @throws LocalizedHttpException if the content is not accessible
443     */
444    public function checkAccess() {
445        $this->checkHasContent(); // Status 404: Not Found
446        $this->checkAccessPermission(); // Status 403: Forbidden
447    }
448
449    /**
450     * @return MutableRevisionRecord|RevisionRecord|null
451     */
452    private function getRevisionRecordForMetadata() {
453        if ( $this->useDefaultSystemMessage() ) {
454            $title = Title::newFromText( $this->getTitleText() );
455            $content = new WikitextContent( $title->getDefaultMessageText() );
456            $revision = new MutableRevisionRecord( $title );
457            $revision->setPageId( 0 );
458            $revision->setId( 0 );
459            $revision->setContent(
460                SlotRecord::MAIN,
461                $content
462            );
463        } else {
464            $revision = $this->getTargetRevision();
465        }
466
467        return $revision;
468    }
469
470}