Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
85.00% |
119 / 140 |
|
80.00% |
16 / 20 |
CRAP | |
0.00% |
0 / 1 |
PageContentHelper | |
85.00% |
119 / 140 |
|
80.00% |
16 / 20 |
54.46 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
4 / 4 |
|
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 | |
75.00% |
18 / 24 |
|
0.00% |
0 / 1 |
2.06 | |||
getParamSettings | |
100.00% |
12 / 12 |
|
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 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Rest\Handler\Helper; |
4 | |
5 | use MediaWiki\Config\ServiceOptions; |
6 | use MediaWiki\MainConfigNames; |
7 | use MediaWiki\Message\Message; |
8 | use MediaWiki\Page\ExistingPageRecord; |
9 | use MediaWiki\Page\PageIdentity; |
10 | use MediaWiki\Page\PageLookup; |
11 | use MediaWiki\Permissions\Authority; |
12 | use MediaWiki\Rest\Handler; |
13 | use MediaWiki\Rest\LocalizedHttpException; |
14 | use MediaWiki\Rest\ResponseInterface; |
15 | use MediaWiki\Revision\MutableRevisionRecord; |
16 | use MediaWiki\Revision\RevisionAccessException; |
17 | use MediaWiki\Revision\RevisionLookup; |
18 | use MediaWiki\Revision\RevisionRecord; |
19 | use MediaWiki\Revision\SlotRecord; |
20 | use MediaWiki\Revision\SuppressedDataException; |
21 | use MediaWiki\Title\Title; |
22 | use MediaWiki\Title\TitleFormatter; |
23 | use TextContent; |
24 | use Wikimedia\Message\MessageValue; |
25 | use Wikimedia\ParamValidator\ParamValidator; |
26 | use WikitextContent; |
27 | |
28 | /** |
29 | * @internal for use by core REST infrastructure |
30 | */ |
31 | class PageContentHelper { |
32 | private const MAX_AGE_200 = 5; |
33 | |
34 | /** |
35 | * @internal |
36 | */ |
37 | public const CONSTRUCTOR_OPTIONS = [ |
38 | MainConfigNames::RightsUrl, |
39 | MainConfigNames::RightsText, |
40 | ]; |
41 | |
42 | /** @var ServiceOptions */ |
43 | protected $options; |
44 | |
45 | /** @var RevisionLookup */ |
46 | protected $revisionLookup; |
47 | |
48 | /** @var TitleFormatter */ |
49 | protected $titleFormatter; |
50 | |
51 | /** @var PageLookup */ |
52 | protected $pageLookup; |
53 | |
54 | /** @var Authority|null */ |
55 | protected $authority = null; |
56 | |
57 | /** @var string[] */ |
58 | protected $parameters = null; |
59 | |
60 | /** @var RevisionRecord|false|null */ |
61 | protected $targetRevision = false; |
62 | |
63 | /** @var ExistingPageRecord|false|null */ |
64 | protected $pageRecord = false; |
65 | |
66 | /** @var PageIdentity|false|null */ |
67 | private $pageIdentity = false; |
68 | |
69 | /** |
70 | * @param ServiceOptions $options |
71 | * @param RevisionLookup $revisionLookup |
72 | * @param TitleFormatter $titleFormatter |
73 | * @param PageLookup $pageLookup |
74 | */ |
75 | public function __construct( |
76 | ServiceOptions $options, |
77 | RevisionLookup $revisionLookup, |
78 | TitleFormatter $titleFormatter, |
79 | PageLookup $pageLookup |
80 | ) { |
81 | $this->options = $options; |
82 | $this->revisionLookup = $revisionLookup; |
83 | $this->titleFormatter = $titleFormatter; |
84 | $this->pageLookup = $pageLookup; |
85 | } |
86 | |
87 | /** |
88 | * @param Authority $authority |
89 | * @param string[] $parameters validated parameters |
90 | */ |
91 | public function init( Authority $authority, array $parameters ) { |
92 | $this->authority = $authority; |
93 | $this->parameters = $parameters; |
94 | } |
95 | |
96 | /** |
97 | * @return string|null title text or null if unable to retrieve title |
98 | */ |
99 | public function getTitleText(): ?string { |
100 | return $this->parameters['title'] ?? null; |
101 | } |
102 | |
103 | /** |
104 | * @return ExistingPageRecord|null |
105 | */ |
106 | public function getPage(): ?ExistingPageRecord { |
107 | if ( $this->pageRecord === false ) { |
108 | $titleText = $this->getTitleText(); |
109 | if ( $titleText === null ) { |
110 | return null; |
111 | } |
112 | $this->pageRecord = $this->pageLookup->getExistingPageByText( $titleText ); |
113 | } |
114 | return $this->pageRecord; |
115 | } |
116 | |
117 | public function getPageIdentity(): ?PageIdentity { |
118 | if ( $this->pageIdentity === false ) { |
119 | $this->pageIdentity = $this->getPage(); |
120 | } |
121 | |
122 | if ( $this->pageIdentity === null ) { |
123 | $titleText = $this->getTitleText(); |
124 | if ( $titleText === null ) { |
125 | return null; |
126 | } |
127 | $this->pageIdentity = $this->pageLookup->getPageByText( $titleText ); |
128 | } |
129 | |
130 | return $this->pageIdentity; |
131 | } |
132 | |
133 | /** |
134 | * Returns the target revision. No permission checks are applied. |
135 | * |
136 | * @return RevisionRecord|null latest revision or null if unable to retrieve revision |
137 | */ |
138 | public function getTargetRevision(): ?RevisionRecord { |
139 | if ( $this->targetRevision === false ) { |
140 | $page = $this->getPage(); |
141 | if ( $page ) { |
142 | $this->targetRevision = $this->revisionLookup->getRevisionByTitle( $page ); |
143 | } else { |
144 | $this->targetRevision = null; |
145 | } |
146 | } |
147 | return $this->targetRevision; |
148 | } |
149 | |
150 | // Default to main slot |
151 | public function getRole(): string { |
152 | return SlotRecord::MAIN; |
153 | } |
154 | |
155 | /** |
156 | * @return TextContent |
157 | * @throws LocalizedHttpException slot content is not TextContent or RevisionRecord/Slot is inaccessible |
158 | */ |
159 | public function getContent(): TextContent { |
160 | $revision = $this->getTargetRevision(); |
161 | |
162 | if ( !$revision ) { |
163 | $titleText = $this->getTitleText() ?? ''; |
164 | throw new LocalizedHttpException( |
165 | MessageValue::new( 'rest-no-revision' )->plaintextParams( $titleText ), |
166 | 404 |
167 | ); |
168 | } |
169 | |
170 | $slotRole = $this->getRole(); |
171 | |
172 | try { |
173 | $content = $revision |
174 | ->getSlot( $slotRole, RevisionRecord::FOR_THIS_USER, $this->authority ) |
175 | ->getContent() |
176 | ->convert( CONTENT_MODEL_TEXT ); |
177 | if ( !( $content instanceof TextContent ) ) { |
178 | throw new LocalizedHttpException( MessageValue::new( 'rest-page-source-type-error' ), 400 ); |
179 | } |
180 | } catch ( SuppressedDataException $e ) { |
181 | throw new LocalizedHttpException( |
182 | MessageValue::new( 'rest-permission-denied-revision' )->numParams( $revision->getId() ), |
183 | 403 |
184 | ); |
185 | } catch ( RevisionAccessException $e ) { |
186 | throw new LocalizedHttpException( |
187 | MessageValue::new( 'rest-nonexistent-revision' )->numParams( $revision->getId() ), |
188 | 404 |
189 | ); |
190 | } |
191 | return $content; |
192 | } |
193 | |
194 | /** |
195 | * @return bool |
196 | */ |
197 | public function isAccessible(): bool { |
198 | $page = $this->getPageIdentity(); |
199 | return $page && $this->authority->probablyCan( 'read', $page ); |
200 | } |
201 | |
202 | /** |
203 | * Returns an ETag representing a page's source. The ETag assumes a page's source has changed |
204 | * if the latest revision of a page has been made private, un-readable for another reason, |
205 | * or a newer revision exists. |
206 | * @return string|null |
207 | */ |
208 | public function getETag(): ?string { |
209 | $revision = $this->getTargetRevision(); |
210 | $revId = $revision ? $revision->getId() : 'e0'; |
211 | |
212 | $isAccessible = $this->isAccessible(); |
213 | $accessibleTag = $isAccessible ? 'a1' : 'a0'; |
214 | |
215 | $revisionTag = $revId . $accessibleTag; |
216 | return '"' . sha1( $revisionTag ) . '"'; |
217 | } |
218 | |
219 | /** |
220 | * @return string|null |
221 | */ |
222 | public function getLastModified(): ?string { |
223 | if ( !$this->isAccessible() ) { |
224 | return null; |
225 | } |
226 | |
227 | $revision = $this->getTargetRevision(); |
228 | if ( $revision ) { |
229 | return $revision->getTimestamp(); |
230 | } |
231 | return null; |
232 | } |
233 | |
234 | /** |
235 | * Checks whether content exists. Permission checks are not considered. |
236 | * |
237 | * @return bool |
238 | */ |
239 | public function hasContent(): bool { |
240 | return $this->useDefaultSystemMessage() || (bool)$this->getPage(); |
241 | } |
242 | |
243 | /** |
244 | * @return array |
245 | */ |
246 | public function constructMetadata(): array { |
247 | if ( $this->useDefaultSystemMessage() ) { |
248 | $title = Title::newFromText( $this->getTitleText() ); |
249 | $content = new WikitextContent( $title->getDefaultMessageText() ); |
250 | $revision = new MutableRevisionRecord( $title ); |
251 | $revision->setPageId( 0 ); |
252 | $revision->setId( 0 ); |
253 | $revision->setContent( SlotRecord::MAIN, $content ); |
254 | } else { |
255 | $revision = $this->getTargetRevision(); |
256 | } |
257 | |
258 | $page = $revision->getPage(); |
259 | return [ |
260 | 'id' => $page->getId(), |
261 | 'key' => $this->titleFormatter->getPrefixedDBkey( $page ), |
262 | 'title' => $this->titleFormatter->getPrefixedText( $page ), |
263 | 'latest' => [ |
264 | 'id' => $revision->getId(), |
265 | 'timestamp' => wfTimestampOrNull( TS_ISO_8601, $revision->getTimestamp() ) |
266 | ], |
267 | 'content_model' => $revision->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ) |
268 | ->getModel(), |
269 | 'license' => [ |
270 | 'url' => $this->options->get( MainConfigNames::RightsUrl ), |
271 | 'title' => $this->options->get( MainConfigNames::RightsText ) |
272 | ], |
273 | ]; |
274 | } |
275 | |
276 | /** |
277 | * @return array[] |
278 | */ |
279 | public function getParamSettings(): array { |
280 | return [ |
281 | 'title' => [ |
282 | Handler::PARAM_SOURCE => 'path', |
283 | ParamValidator::PARAM_TYPE => 'string', |
284 | ParamValidator::PARAM_REQUIRED => true, |
285 | ], |
286 | 'redirect' => [ |
287 | Handler::PARAM_SOURCE => 'query', |
288 | ParamValidator::PARAM_TYPE => [ 'no' ], |
289 | ParamValidator::PARAM_REQUIRED => false, |
290 | ] |
291 | ]; |
292 | } |
293 | |
294 | /** |
295 | * Sets the 'Cache-Control' header no more then provided $expiry. |
296 | * @param ResponseInterface $response |
297 | * @param int|null $expiry |
298 | */ |
299 | public function setCacheControl( ResponseInterface $response, int $expiry = null ) { |
300 | if ( $expiry === null ) { |
301 | $maxAge = self::MAX_AGE_200; |
302 | } else { |
303 | $maxAge = min( self::MAX_AGE_200, $expiry ); |
304 | } |
305 | $response->setHeader( |
306 | 'Cache-Control', |
307 | 'max-age=' . $maxAge |
308 | ); |
309 | } |
310 | |
311 | /** |
312 | * If the page is a system message page. When the content gets |
313 | * overridden to create an actual page, this method returns false. |
314 | * |
315 | * @return bool |
316 | */ |
317 | public function useDefaultSystemMessage(): bool { |
318 | return $this->getDefaultSystemMessage() !== null && $this->getPage() === null; |
319 | } |
320 | |
321 | /** |
322 | * @return Message|null |
323 | */ |
324 | public function getDefaultSystemMessage(): ?Message { |
325 | $title = Title::newFromText( $this->getTitleText() ); |
326 | |
327 | return $title ? $title->getDefaultSystemMessage() : null; |
328 | } |
329 | |
330 | /** |
331 | * @throws LocalizedHttpException if access is not allowed |
332 | */ |
333 | public function checkAccessPermission() { |
334 | $titleText = $this->getTitleText() ?? ''; |
335 | |
336 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Validated by hasContent |
337 | if ( !$this->isAccessible() || !$this->authority->authorizeRead( 'read', $this->getPageIdentity() ) ) { |
338 | throw new LocalizedHttpException( |
339 | MessageValue::new( 'rest-permission-denied-title' )->plaintextParams( $titleText ), |
340 | 403 |
341 | ); |
342 | } |
343 | } |
344 | |
345 | /** |
346 | * @throws LocalizedHttpException if no content is available |
347 | */ |
348 | public function checkHasContent() { |
349 | $titleText = $this->getTitleText() ?? ''; |
350 | |
351 | $page = $this->getPageIdentity(); |
352 | if ( !$page ) { |
353 | throw new LocalizedHttpException( |
354 | MessageValue::new( 'rest-invalid-title' )->plaintextParams( $titleText ), |
355 | 404 |
356 | ); |
357 | } |
358 | |
359 | if ( !$this->hasContent() ) { |
360 | // needs to check if it's possibly a variant title |
361 | throw new LocalizedHttpException( |
362 | MessageValue::new( 'rest-nonexistent-title' )->plaintextParams( $titleText ), |
363 | 404 |
364 | ); |
365 | } |
366 | |
367 | $revision = $this->getTargetRevision(); |
368 | if ( !$revision && !$this->useDefaultSystemMessage() ) { |
369 | throw new LocalizedHttpException( |
370 | MessageValue::new( 'rest-no-revision' )->plaintextParams( $titleText ), |
371 | 404 |
372 | ); |
373 | } |
374 | } |
375 | |
376 | /** |
377 | * @throws LocalizedHttpException if the content is not accessible |
378 | */ |
379 | public function checkAccess() { |
380 | $this->checkHasContent(); // Status 404: Not Found |
381 | $this->checkAccessPermission(); // Status 403: Forbidden |
382 | } |
383 | |
384 | } |