Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
71.51% |
128 / 179 |
|
78.26% |
18 / 23 |
CRAP | |
0.00% |
0 / 1 |
PageContentHelper | |
71.51% |
128 / 179 |
|
78.26% |
18 / 23 |
114.54 | |
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% |
17 / 17 |
|
100.00% |
1 / 1 |
1 | |||
constructRestbaseCompatibleMetadata | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
12 | |||
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 $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 | } |