Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.67% covered (warning)
86.67%
156 / 180
82.61% covered (warning)
82.61%
19 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageContentHelper
86.67% covered (warning)
86.67%
156 / 180
82.61% covered (warning)
82.61%
19 / 23
60.91
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
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
5
 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 $changeTagsStore;
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 $changeTagsStore
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->changeTagsStore = $changeTagsStore;
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    public function getPage(): ?ExistingPageRecord {
115        if ( $this->pageRecord === false ) {
116            $titleText = $this->getTitleText();
117            if ( $titleText === null ) {
118                return null;
119            }
120            $this->pageRecord = $this->pageLookup->getExistingPageByText( $titleText );
121        }
122        return $this->pageRecord;
123    }
124
125    public function getPageIdentity(): ?PageIdentity {
126        if ( $this->pageIdentity === false ) {
127            $this->pageIdentity = $this->getPage();
128        }
129
130        if ( $this->pageIdentity === null ) {
131            $titleText = $this->getTitleText();
132            if ( $titleText === null ) {
133                return null;
134            }
135            $this->pageIdentity = $this->pageLookup->getPageByText( $titleText );
136        }
137
138        return $this->pageIdentity;
139    }
140
141    /**
142     * Returns the target revision. No permission checks are applied.
143     *
144     * @return RevisionRecord|null latest revision or null if unable to retrieve revision
145     */
146    public function getTargetRevision(): ?RevisionRecord {
147        if ( $this->targetRevision === false ) {
148            $page = $this->getPage();
149            if ( $page ) {
150                $this->targetRevision = $this->revisionLookup->getRevisionByTitle( $page );
151            } else {
152                $this->targetRevision = null;
153            }
154        }
155        return $this->targetRevision;
156    }
157
158    // Default to main slot
159    public function getRole(): string {
160        return SlotRecord::MAIN;
161    }
162
163    /**
164     * @return TextContent
165     * @throws LocalizedHttpException slot content is not TextContent or RevisionRecord/Slot is inaccessible
166     */
167    public function getContent(): TextContent {
168        $revision = $this->getTargetRevision();
169
170        if ( !$revision ) {
171            $titleText = $this->getTitleText() ?? '';
172            throw new LocalizedHttpException(
173                MessageValue::new( 'rest-no-revision' )->plaintextParams( $titleText ),
174                404
175            );
176        }
177
178        $slotRole = $this->getRole();
179
180        try {
181            $content = $revision
182                ->getSlot( $slotRole, RevisionRecord::FOR_THIS_USER, $this->authority )
183                ->getContent()
184                ->convert( CONTENT_MODEL_TEXT );
185            if ( !( $content instanceof TextContent ) ) {
186                throw new LocalizedHttpException( MessageValue::new( 'rest-page-source-type-error' ), 400 );
187            }
188        } catch ( SuppressedDataException $e ) {
189            throw new LocalizedHttpException(
190                MessageValue::new( 'rest-permission-denied-revision' )->numParams( $revision->getId() ),
191                403
192            );
193        } catch ( RevisionAccessException $e ) {
194            throw new LocalizedHttpException(
195                MessageValue::new( 'rest-nonexistent-revision' )->numParams( $revision->getId() ),
196                404
197            );
198        }
199        return $content;
200    }
201
202    public function isAccessible(): bool {
203        $page = $this->getPageIdentity();
204        return $page && $this->authority->probablyCan( 'read', $page );
205    }
206
207    /**
208     * Returns an ETag representing a page's source. The ETag assumes a page's source has changed
209     * if the latest revision of a page has been made private, un-readable for another reason,
210     * or a newer revision exists.
211     * @return string|null
212     */
213    public function getETag(): ?string {
214        $revision = $this->getTargetRevision();
215        $revId = $revision ? $revision->getId() : 'e0';
216
217        $isAccessible = $this->isAccessible();
218        $accessibleTag = $isAccessible ? 'a1' : 'a0';
219
220        $revisionTag = $revId . $accessibleTag;
221        return '"' . sha1( $revisionTag ) . '"';
222    }
223
224    public function getLastModified(): ?string {
225        if ( !$this->isAccessible() ) {
226            return null;
227        }
228
229        $revision = $this->getTargetRevision();
230        if ( $revision ) {
231            return $revision->getTimestamp();
232        }
233        return null;
234    }
235
236    /**
237     * Checks whether content exists. Permission checks are not considered.
238     */
239    public function hasContent(): bool {
240        return $this->useDefaultSystemMessage() || (bool)$this->getPage();
241    }
242
243    public function constructMetadata(): array {
244        $revision = $this->getRevisionRecordForMetadata();
245
246        $page = $revision->getPage();
247        return [
248            'id' => $page->getId(),
249            'key' => $this->titleFormatter->getPrefixedDBkey( $page ),
250            'title' => $this->titleFormatter->getPrefixedText( $page ),
251            'latest' => [
252                'id' => $revision->getId(),
253                'timestamp' => wfTimestampOrNull( TS_ISO_8601, $revision->getTimestamp() )
254            ],
255            'content_model' => $revision->getMainContentModel(),
256            'license' => [
257                'url' => $this->options->get( MainConfigNames::RightsUrl ),
258                'title' => $this->options->get( MainConfigNames::RightsText )
259            ],
260        ];
261    }
262
263    public function constructRestbaseCompatibleMetadata(): array {
264        $revision = $this->getRevisionRecordForMetadata();
265
266        $page = $revision->getPage();
267        $title = $this->titleFactory->newFromPageIdentity( $page );
268
269        $tags = $this->changeTagsStore->getTags(
270            $this->dbProvider->getReplicaDatabase(),
271            null, $revision->getId(), null
272        );
273
274        $restrictions = [];
275
276        if ( $revision->isDeleted( RevisionRecord::DELETED_COMMENT ) ) {
277            $restrictions[] = 'commenthidden';
278        }
279
280        if ( $revision->isDeleted( RevisionRecord::DELETED_USER ) ) {
281            $restrictions[] = 'userhidden';
282        }
283
284        $publicUser = $revision->getUser();
285        $publicComment = $revision->getComment();
286
287        return [
288            'title' => $title->getPrefixedDBkey(),
289            'page_id' => $page->getId(),
290            'rev' => $revision->getId(),
291
292            // We could look up the tid from a ParserOutput, but it's expensive,
293            // and the tid can't be used for anything anymore anyway.
294            // Don't use an empty string though, that may break routing when the
295            // value is used as a path parameter.
296            'tid' => 'DUMMY',
297
298            'namespace' => $page->getNamespace(),
299            'user_id' => $revision->getUser( RevisionRecord::RAW )->getId(),
300            'user_text' => $publicUser ? $publicUser->getName() : null,
301            'comment' => $publicComment ? $publicComment->text : null,
302            'timestamp' => wfTimestampOrNull( TS_ISO_8601, $revision->getTimestamp() ),
303            'tags' => $tags,
304            'restrictions' => $restrictions,
305            'page_language' => $title->getPageLanguage()->getCode(),
306            'redirect' => $title->isRedirect()
307        ];
308    }
309
310    /**
311     * @return array[]
312     */
313    public function getParamSettings(): array {
314        return [
315            'title' => [
316                Handler::PARAM_SOURCE => 'path',
317                ParamValidator::PARAM_TYPE => 'string',
318                ParamValidator::PARAM_REQUIRED => true,
319                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-page-content-title' ),
320            ],
321            'redirect' => [
322                Handler::PARAM_SOURCE => 'query',
323                ParamValidator::PARAM_TYPE => 'boolean',
324                ParamValidator::PARAM_REQUIRED => false,
325                ParamValidator::PARAM_DEFAULT => true,
326                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-page-content-redirect' ),
327            ]
328        ];
329    }
330
331    /**
332     * Whether the handler is allowed to follow redirects, according to the
333     * request parameters.
334     *
335     * Handlers that can follow wiki redirects can use this to give clients
336     * control over the redirect handling behavior.
337     */
338    public function getRedirectsAllowed(): bool {
339        return $this->parameters['redirect'] ?? true;
340    }
341
342    /**
343     * Sets the 'Cache-Control' header no more then provided $expiry.
344     * @param ResponseInterface $response
345     * @param int|null $expiry
346     */
347    public function setCacheControl( ResponseInterface $response, ?int $expiry = null ) {
348        if ( $expiry === null ) {
349            $maxAge = self::MAX_AGE_200;
350        } else {
351            $maxAge = min( self::MAX_AGE_200, $expiry );
352        }
353        $response->setHeader(
354            'Cache-Control',
355            'max-age=' . $maxAge
356        );
357    }
358
359    /**
360     * If the page is a system message page. When the content gets
361     * overridden to create an actual page, this method returns false.
362     */
363    public function useDefaultSystemMessage(): bool {
364        return $this->getDefaultSystemMessage() !== null && $this->getPage() === null;
365    }
366
367    public function getDefaultSystemMessage(): ?Message {
368        $title = Title::newFromText( $this->getTitleText() );
369
370        return $title ? $title->getDefaultSystemMessage() : null;
371    }
372
373    /**
374     * @throws LocalizedHttpException if access is not allowed
375     */
376    public function checkAccessPermission() {
377        $titleText = $this->getTitleText() ?? '';
378
379        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Validated by hasContent
380        if ( !$this->isAccessible() || !$this->authority->authorizeRead( 'read', $this->getPageIdentity() ) ) {
381            throw new LocalizedHttpException(
382                MessageValue::new( 'rest-permission-denied-title' )->plaintextParams( $titleText ),
383                403
384            );
385        }
386    }
387
388    /**
389     * @throws LocalizedHttpException if no content is available
390     */
391    public function checkHasContent() {
392        $titleText = $this->getTitleText() ?? '';
393
394        $page = $this->getPageIdentity();
395        if ( !$page ) {
396            throw new LocalizedHttpException(
397                MessageValue::new( 'rest-invalid-title' )->plaintextParams( $titleText ),
398                404
399            );
400        }
401
402        if ( !$this->hasContent() ) {
403            // needs to check if it's possibly a variant title
404            throw new LocalizedHttpException(
405                MessageValue::new( 'rest-nonexistent-title' )->plaintextParams( $titleText ),
406                404
407            );
408        }
409
410        $revision = $this->getTargetRevision();
411        if ( !$revision && !$this->useDefaultSystemMessage() ) {
412            throw new LocalizedHttpException(
413                MessageValue::new( 'rest-no-revision' )->plaintextParams( $titleText ),
414                404
415            );
416        }
417    }
418
419    /**
420     * @throws LocalizedHttpException if the content is not accessible
421     */
422    public function checkAccess() {
423        $this->checkHasContent(); // Status 404: Not Found
424        $this->checkAccessPermission(); // Status 403: Forbidden
425    }
426
427    /**
428     * @return MutableRevisionRecord|RevisionRecord|null
429     */
430    private function getRevisionRecordForMetadata() {
431        if ( $this->useDefaultSystemMessage() ) {
432            $title = Title::newFromText( $this->getTitleText() );
433            $content = new WikitextContent( $title->getDefaultMessageText() );
434            $revision = new MutableRevisionRecord( $title );
435            $revision->setPageId( 0 );
436            $revision->setId( 0 );
437            $revision->setContent(
438                SlotRecord::MAIN,
439                $content
440            );
441        } else {
442            $revision = $this->getTargetRevision();
443        }
444
445        return $revision;
446    }
447
448}