Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.35% covered (warning)
71.35%
127 / 178
78.26% covered (warning)
78.26%
18 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageContentHelper
71.35% covered (warning)
71.35%
127 / 178
78.26% covered (warning)
78.26%
18 / 23
115.60
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%
16 / 16
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->getMainContentModel(),
270            'license' => [
271                'url' => $this->options->get( MainConfigNames::RightsUrl ),
272                'title' => $this->options->get( MainConfigNames::RightsText )
273            ],
274        ];
275    }
276
277    /**
278     * @return array
279     */
280    public function constructRestbaseCompatibleMetadata(): array {
281        $revision = $this->getRevisionRecordForMetadata();
282
283        $page = $revision->getPage();
284        $title = $this->titleFactory->newFromPageIdentity( $page );
285
286        $tags = $this->changeTagStore->getTags(
287            $this->dbProvider->getReplicaDatabase(),
288            null, $revision->getId(), null
289        );
290
291        $restrictions = [];
292
293        if ( $revision->isDeleted( RevisionRecord::DELETED_COMMENT ) ) {
294            $restrictions[] = 'commenthidden';
295        }
296
297        if ( $revision->isDeleted( RevisionRecord::DELETED_USER ) ) {
298            $restrictions[] = 'userhidden';
299        }
300
301        return [
302            'title' => $title->getPrefixedDBkey(),
303            'page_id' => $page->getId(),
304            'rev' => $revision->getId(),
305
306            // We could look up the tid from a ParserOutput, but it's expensive,
307            // and the tid can't be used for anything anymore anyway.
308            // Don't use an empty string though, that may break routing when the
309            // value is used as a path parameter.
310            'tid' => 'DUMMY',
311
312            'namespace' => $page->getNamespace(),
313            'user_id' => $revision->getUser( RevisionRecord::RAW )->getId(),
314            'user_text' => $revision->getUser( RevisionRecord::FOR_PUBLIC )->getName(),
315            'timestamp' => wfTimestampOrNull( TS_ISO_8601, $revision->getTimestamp() ),
316            'comment' => $revision->getComment()->text,
317            'tags' => $tags,
318            'restrictions' => $restrictions,
319            'page_language' => $title->getPageLanguage()->getCode(),
320            'redirect' => $title->isRedirect()
321        ];
322    }
323
324    /**
325     * @return array[]
326     */
327    public function getParamSettings(): array {
328        return [
329            'title' => [
330                Handler::PARAM_SOURCE => 'path',
331                ParamValidator::PARAM_TYPE => 'string',
332                ParamValidator::PARAM_REQUIRED => true,
333                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-page-content-title' ),
334            ],
335            'redirect' => [
336                Handler::PARAM_SOURCE => 'query',
337                ParamValidator::PARAM_TYPE => 'boolean',
338                ParamValidator::PARAM_REQUIRED => false,
339                ParamValidator::PARAM_DEFAULT => true,
340                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-page-content-redirect' ),
341            ]
342        ];
343    }
344
345    /**
346     * Whether the handler is allowed to follow redirects, according to the
347     * request parameters.
348     *
349     * Handlers that can follow wiki redirects can use this to give clients
350     * control over the redirect handling behavior.
351     *
352     * @return bool
353     */
354    public function getRedirectsAllowed(): bool {
355        return $this->parameters['redirect'] ?? true;
356    }
357
358    /**
359     * Sets the 'Cache-Control' header no more then provided $expiry.
360     * @param ResponseInterface $response
361     * @param int|null $expiry
362     */
363    public function setCacheControl( ResponseInterface $response, ?int $expiry = null ) {
364        if ( $expiry === null ) {
365            $maxAge = self::MAX_AGE_200;
366        } else {
367            $maxAge = min( self::MAX_AGE_200, $expiry );
368        }
369        $response->setHeader(
370            'Cache-Control',
371            'max-age=' . $maxAge
372        );
373    }
374
375    /**
376     * If the page is a system message page. When the content gets
377     * overridden to create an actual page, this method returns false.
378     *
379     * @return bool
380     */
381    public function useDefaultSystemMessage(): bool {
382        return $this->getDefaultSystemMessage() !== null && $this->getPage() === null;
383    }
384
385    /**
386     * @return Message|null
387     */
388    public function getDefaultSystemMessage(): ?Message {
389        $title = Title::newFromText( $this->getTitleText() );
390
391        return $title ? $title->getDefaultSystemMessage() : null;
392    }
393
394    /**
395     * @throws LocalizedHttpException if access is not allowed
396     */
397    public function checkAccessPermission() {
398        $titleText = $this->getTitleText() ?? '';
399
400        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Validated by hasContent
401        if ( !$this->isAccessible() || !$this->authority->authorizeRead( 'read', $this->getPageIdentity() ) ) {
402            throw new LocalizedHttpException(
403                MessageValue::new( 'rest-permission-denied-title' )->plaintextParams( $titleText ),
404                403
405            );
406        }
407    }
408
409    /**
410     * @throws LocalizedHttpException if no content is available
411     */
412    public function checkHasContent() {
413        $titleText = $this->getTitleText() ?? '';
414
415        $page = $this->getPageIdentity();
416        if ( !$page ) {
417            throw new LocalizedHttpException(
418                MessageValue::new( 'rest-invalid-title' )->plaintextParams( $titleText ),
419                404
420            );
421        }
422
423        if ( !$this->hasContent() ) {
424            // needs to check if it's possibly a variant title
425            throw new LocalizedHttpException(
426                MessageValue::new( 'rest-nonexistent-title' )->plaintextParams( $titleText ),
427                404
428            );
429        }
430
431        $revision = $this->getTargetRevision();
432        if ( !$revision && !$this->useDefaultSystemMessage() ) {
433            throw new LocalizedHttpException(
434                MessageValue::new( 'rest-no-revision' )->plaintextParams( $titleText ),
435                404
436            );
437        }
438    }
439
440    /**
441     * @throws LocalizedHttpException if the content is not accessible
442     */
443    public function checkAccess() {
444        $this->checkHasContent(); // Status 404: Not Found
445        $this->checkAccessPermission(); // Status 403: Forbidden
446    }
447
448    /**
449     * @return MutableRevisionRecord|RevisionRecord|null
450     */
451    private function getRevisionRecordForMetadata() {
452        if ( $this->useDefaultSystemMessage() ) {
453            $title = Title::newFromText( $this->getTitleText() );
454            $content = new WikitextContent( $title->getDefaultMessageText() );
455            $revision = new MutableRevisionRecord( $title );
456            $revision->setPageId( 0 );
457            $revision->setId( 0 );
458            $revision->setContent(
459                SlotRecord::MAIN,
460                $content
461            );
462        } else {
463            $revision = $this->getTargetRevision();
464        }
465
466        return $revision;
467    }
468
469}