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