Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
86.67% |
156 / 180 |
|
82.61% |
19 / 23 |
CRAP | |
0.00% |
0 / 1 |
PageContentHelper | |
86.67% |
156 / 180 |
|
82.61% |
19 / 23 |
60.91 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
init | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getTitleText | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getPage | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getPageIdentity | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
getTargetRevision | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getRole | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getContent | |
60.00% |
15 / 25 |
|
0.00% |
0 / 1 |
6.60 | |||
isAccessible | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
getETag | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getLastModified | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
hasContent | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
constructMetadata | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
1 | |||
constructRestbaseCompatibleMetadata | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
5 | |||
getParamSettings | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
1 | |||
getRedirectsAllowed | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setCacheControl | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
useDefaultSystemMessage | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
getDefaultSystemMessage | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
checkAccessPermission | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
checkHasContent | |
77.78% |
14 / 18 |
|
0.00% |
0 / 1 |
5.27 | |||
checkAccess | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getRevisionRecordForMetadata | |
25.00% |
3 / 12 |
|
0.00% |
0 / 1 |
3.69 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Rest\Handler\Helper; |
4 | |
5 | use MediaWiki\ChangeTags\ChangeTagsStore; |
6 | use MediaWiki\Config\ServiceOptions; |
7 | use MediaWiki\Content\TextContent; |
8 | use MediaWiki\Content\WikitextContent; |
9 | use MediaWiki\MainConfigNames; |
10 | use MediaWiki\Message\Message; |
11 | use MediaWiki\Page\ExistingPageRecord; |
12 | use MediaWiki\Page\PageIdentity; |
13 | use MediaWiki\Page\PageLookup; |
14 | use MediaWiki\Permissions\Authority; |
15 | use MediaWiki\Rest\Handler; |
16 | use MediaWiki\Rest\LocalizedHttpException; |
17 | use MediaWiki\Rest\ResponseInterface; |
18 | use MediaWiki\Revision\MutableRevisionRecord; |
19 | use MediaWiki\Revision\RevisionAccessException; |
20 | use MediaWiki\Revision\RevisionLookup; |
21 | use MediaWiki\Revision\RevisionRecord; |
22 | use MediaWiki\Revision\SlotRecord; |
23 | use MediaWiki\Revision\SuppressedDataException; |
24 | use MediaWiki\Title\Title; |
25 | use MediaWiki\Title\TitleFactory; |
26 | use MediaWiki\Title\TitleFormatter; |
27 | use Wikimedia\Message\MessageValue; |
28 | use Wikimedia\ParamValidator\ParamValidator; |
29 | use Wikimedia\Rdbms\IConnectionProvider; |
30 | |
31 | /** |
32 | * @internal for use by core REST infrastructure |
33 | */ |
34 | class 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 | } |