Merge "Make DBAccessBase use DBConnRef, rename $wiki, and hide getLoadBalancer()"
[lhc/web/wiklou.git] / includes / Revision / RenderedRevision.php
1 <?php
2 /**
3 * This file is part of MediaWiki.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23 namespace MediaWiki\Revision;
24
25 use InvalidArgumentException;
26 use LogicException;
27 use ParserOptions;
28 use ParserOutput;
29 use Psr\Log\LoggerInterface;
30 use Psr\Log\NullLogger;
31 use Revision;
32 use Title;
33 use User;
34 use Content;
35 use Wikimedia\Assert\Assert;
36
37 /**
38 * RenderedRevision represents the rendered representation of a revision. It acts as a lazy provider
39 * of ParserOutput objects for the revision's individual slots, as well as a combined ParserOutput
40 * of all slots.
41 *
42 * @since 1.32
43 */
44 class RenderedRevision implements SlotRenderingProvider {
45
46 /**
47 * @var Title
48 */
49 private $title;
50
51 /** @var RevisionRecord */
52 private $revision;
53
54 /**
55 * @var ParserOptions
56 */
57 private $options;
58
59 /**
60 * @var int Audience to check when accessing content.
61 */
62 private $audience = RevisionRecord::FOR_PUBLIC;
63
64 /**
65 * @var User|null The user to use for audience checks during content access.
66 */
67 private $forUser = null;
68
69 /**
70 * @var ParserOutput|null The combined ParserOutput for the revision,
71 * initialized lazily by getRevisionParserOutput().
72 */
73 private $revisionOutput = null;
74
75 /**
76 * @var ParserOutput[] The ParserOutput for each slot,
77 * initialized lazily by getSlotParserOutput().
78 */
79 private $slotsOutput = [];
80
81 /**
82 * @var callable Callback for combining slot output into revision output.
83 * Signature: function ( RenderedRevision $this ): ParserOutput.
84 */
85 private $combineOutput;
86
87 /**
88 * @var LoggerInterface For profiling ParserOutput re-use.
89 */
90 private $saveParseLogger;
91
92 /**
93 * @note Application logic should not instantiate RenderedRevision instances directly,
94 * but should use a RevisionRenderer instead.
95 *
96 * @param Title $title
97 * @param RevisionRecord $revision The revision to render. The content for rendering will be
98 * taken from this RevisionRecord. However, if the RevisionRecord is not complete
99 * according isReadyForInsertion(), but a revision ID is known, the parser may load
100 * the revision from the database if it needs revision meta data to handle magic
101 * words like {{REVISIONUSER}}.
102 * @param ParserOptions $options
103 * @param callable $combineOutput Callback for combining slot output into revision output.
104 * Signature: function ( RenderedRevision $this ): ParserOutput.
105 * @param int $audience Use RevisionRecord::FOR_PUBLIC, FOR_THIS_USER, or RAW.
106 * @param User|null $forUser Required if $audience is FOR_THIS_USER.
107 */
108 public function __construct(
109 Title $title,
110 RevisionRecord $revision,
111 ParserOptions $options,
112 callable $combineOutput,
113 $audience = RevisionRecord::FOR_PUBLIC,
114 User $forUser = null
115 ) {
116 $this->title = $title;
117 $this->options = $options;
118
119 $this->setRevisionInternal( $revision );
120
121 $this->combineOutput = $combineOutput;
122 $this->saveParseLogger = new NullLogger();
123
124 if ( $audience === RevisionRecord::FOR_THIS_USER && !$forUser ) {
125 throw new InvalidArgumentException(
126 'User must be specified when setting audience to FOR_THIS_USER'
127 );
128 }
129
130 $this->audience = $audience;
131 $this->forUser = $forUser;
132 }
133
134 /**
135 * @param LoggerInterface $saveParseLogger
136 */
137 public function setSaveParseLogger( LoggerInterface $saveParseLogger ) {
138 $this->saveParseLogger = $saveParseLogger;
139 }
140
141 /**
142 * @return bool Whether the revision's content has been hidden from unprivileged users.
143 */
144 public function isContentDeleted() {
145 return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
146 }
147
148 /**
149 * @return RevisionRecord
150 */
151 public function getRevision() {
152 return $this->revision;
153 }
154
155 /**
156 * @return ParserOptions
157 */
158 public function getOptions() {
159 return $this->options;
160 }
161
162 /**
163 * Sets a ParserOutput to be returned by getRevisionParserOutput().
164 *
165 * @note For internal use by RevisionRenderer only! This method may be modified
166 * or removed without notice per the deprecation policy.
167 *
168 * @internal
169 *
170 * @param ParserOutput $output
171 */
172 public function setRevisionParserOutput( ParserOutput $output ) {
173 $this->revisionOutput = $output;
174
175 // If there is only one slot, we assume that the combined output is identical
176 // with the main slot's output. This is intended to prevent a redundant re-parse of
177 // the content in case getSlotParserOutput( SlotRecord::MAIN ) is called, for instance
178 // from ContentHandler::getSecondaryDataUpdates.
179 if ( $this->revision->getSlotRoles() === [ SlotRecord::MAIN ] ) {
180 $this->slotsOutput[ SlotRecord::MAIN ] = $output;
181 }
182 }
183
184 /**
185 * @param array $hints Hints given as an associative array. Known keys:
186 * - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
187 * to just meta-data). Default is to generate HTML.
188 * @phan-param array{generate-html?:bool} $hints
189 *
190 * @return ParserOutput
191 */
192 public function getRevisionParserOutput( array $hints = [] ) {
193 $withHtml = $hints['generate-html'] ?? true;
194
195 if ( !$this->revisionOutput
196 || ( $withHtml && !$this->revisionOutput->hasText() )
197 ) {
198 $output = call_user_func( $this->combineOutput, $this, $hints );
199
200 Assert::postcondition(
201 $output instanceof ParserOutput,
202 'Callback did not return a ParserOutput object!'
203 );
204
205 $this->revisionOutput = $output;
206 }
207
208 return $this->revisionOutput;
209 }
210
211 /**
212 * @param string $role
213 * @param array $hints Hints given as an associative array. Known keys:
214 * - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
215 * to just meta-data). Default is to generate HTML.
216 * @phan-param array{generate-html?:bool} $hints
217 *
218 * @throws SuppressedDataException if the content is not accessible for the audience
219 * specified in the constructor.
220 * @return ParserOutput
221 */
222 public function getSlotParserOutput( $role, array $hints = [] ) {
223 $withHtml = $hints['generate-html'] ?? true;
224
225 if ( !isset( $this->slotsOutput[ $role ] )
226 || ( $withHtml && !$this->slotsOutput[ $role ]->hasText() )
227 ) {
228 $content = $this->revision->getContent( $role, $this->audience, $this->forUser );
229
230 if ( !$content ) {
231 throw new SuppressedDataException(
232 'Access to the content has been suppressed for this audience'
233 );
234 } else {
235 // XXX: allow SlotRoleHandler to control the ParserOutput?
236 $output = $this->getSlotParserOutputUncached( $content, $withHtml );
237
238 if ( $withHtml && !$output->hasText() ) {
239 throw new LogicException(
240 'HTML generation was requested, but '
241 . get_class( $content )
242 . '::getParserOutput() returns a ParserOutput with no text set.'
243 );
244 }
245
246 // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput.
247 $this->options->registerWatcher( null );
248 }
249
250 $this->slotsOutput[ $role ] = $output;
251 }
252
253 return $this->slotsOutput[$role];
254 }
255
256 /**
257 * @note This method exist to make duplicate parses easier to see during profiling
258 * @param Content $content
259 * @param bool $withHtml
260 * @return ParserOutput
261 */
262 private function getSlotParserOutputUncached( Content $content, $withHtml ) {
263 return $content->getParserOutput(
264 $this->title,
265 $this->revision->getId(),
266 $this->options,
267 $withHtml
268 );
269 }
270
271 /**
272 * Updates the RevisionRecord after the revision has been saved. This can be used to discard
273 * and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}}
274 * are re-evaluated.
275 *
276 * @note There should be no need to call this for null-edits.
277 *
278 * @param RevisionRecord $rev
279 */
280 public function updateRevision( RevisionRecord $rev ) {
281 if ( $rev->getId() === $this->revision->getId() ) {
282 return;
283 }
284
285 if ( $this->revision->getId() ) {
286 throw new LogicException( 'RenderedRevision already has a revision with ID '
287 . $this->revision->getId(), ', can\'t update to revision with ID ' . $rev->getId() );
288 }
289
290 if ( !$this->revision->getSlots()->hasSameContent( $rev->getSlots() ) ) {
291 throw new LogicException( 'Cannot update to a revision with different content!' );
292 }
293
294 $this->setRevisionInternal( $rev );
295
296 $this->pruneRevisionSensitiveOutput(
297 $this->revision->getPageId(),
298 $this->revision->getId(),
299 $this->revision->getTimestamp()
300 );
301 }
302
303 /**
304 * Prune any output that depends on the revision ID.
305 *
306 * @param int|bool $actualPageId The actual page id, to check the used speculative page ID
307 * against; false, to not purge on vary-page-id; true, to purge on vary-page-id
308 * unconditionally.
309 * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
310 * against,; false, to not purge on vary-revision-id; true, to purge on
311 * vary-revision-id unconditionally.
312 * @param string|bool $actualRevTimestamp The actual rev timestamp, to check against the
313 * parser output revision timestamp; false, to not purge on vary-revision-timestamp;
314 * true, to purge on vary-revision-timestamp unconditionally.
315 */
316 private function pruneRevisionSensitiveOutput(
317 $actualPageId,
318 $actualRevId,
319 $actualRevTimestamp
320 ) {
321 if ( $this->revisionOutput ) {
322 if ( $this->outputVariesOnRevisionMetaData(
323 $this->revisionOutput,
324 $actualPageId,
325 $actualRevId,
326 $actualRevTimestamp
327 ) ) {
328 $this->revisionOutput = null;
329 }
330 } else {
331 $this->saveParseLogger->debug( __METHOD__ . ": no prepared revision output" );
332 }
333
334 foreach ( $this->slotsOutput as $role => $output ) {
335 if ( $this->outputVariesOnRevisionMetaData(
336 $output,
337 $actualPageId,
338 $actualRevId,
339 $actualRevTimestamp
340 ) ) {
341 unset( $this->slotsOutput[$role] );
342 }
343 }
344 }
345
346 /**
347 * @param RevisionRecord $revision
348 */
349 private function setRevisionInternal( RevisionRecord $revision ) {
350 $this->revision = $revision;
351
352 // Force the parser to use $this->revision to resolve magic words like {{REVISIONUSER}}
353 // if the revision is either known to be complete, or it doesn't have a revision ID set.
354 // If it's incomplete and we have a revision ID, the parser can do better by loading
355 // the revision from the database if needed to handle a magic word.
356 //
357 // The following considerations inform the logic described above:
358 //
359 // 1) If we have a saved revision already loaded, we want the parser to use it, instead of
360 // loading it again.
361 //
362 // 2) If the revision is a fake that wraps some kind of synthetic content, such as an
363 // error message from Article, it should be used directly and things like {{REVISIONUSER}}
364 // should not expected to work, since there may not even be an actual revision to
365 // refer to.
366 //
367 // 3) If the revision is a fake constructed around a Title, a Content object, and
368 // a revision ID, to provide backwards compatibility to code that has access to those
369 // but not to a complete RevisionRecord for rendering, then we want the Parser to
370 // load the actual revision from the database when it encounters a magic word like
371 // {{REVISIONUSER}}, but we don't want to load that revision ahead of time just in case.
372 //
373 // 4) Previewing an edit to a template should use the submitted unsaved
374 // MutableRevisionRecord for self-transclusions in the template's documentation (see T7278).
375 // That revision would be complete except for the ID field.
376 //
377 // 5) Pre-save transform would provide a RevisionRecord that has all meta-data but is
378 // incomplete due to not yet having content set. However, since it doesn't have a revision
379 // ID either, the below code would still force it to be used, allowing
380 // {{subst::REVISIONUSER}} to function as expected.
381
382 if ( $this->revision->isReadyForInsertion() || !$this->revision->getId() ) {
383 $title = $this->title;
384 $oldCallback = $this->options->getCurrentRevisionCallback();
385 $this->options->setCurrentRevisionCallback(
386 function ( Title $parserTitle, $parser = false ) use ( $title, $oldCallback ) {
387 if ( $title->equals( $parserTitle ) ) {
388 $legacyRevision = new Revision( $this->revision );
389 return $legacyRevision;
390 } else {
391 return call_user_func( $oldCallback, $parserTitle, $parser );
392 }
393 }
394 );
395 }
396 }
397
398 /**
399 * @param ParserOutput $out
400 * @param int|bool $actualPageId The actual page id, to check the used speculative page ID
401 * against; false, to not purge on vary-page-id; true, to purge on vary-page-id
402 * unconditionally.
403 * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
404 * against,; false, to not purge on vary-revision-id; true, to purge on
405 * vary-revision-id unconditionally.
406 * @param string|bool $actualRevTimestamp The actual rev timestamp, to check against the
407 * parser output revision timestamp; false, to not purge on vary-revision-timestamp;
408 * true, to purge on vary-revision-timestamp unconditionally.
409 * @return bool
410 */
411 private function outputVariesOnRevisionMetaData(
412 ParserOutput $out,
413 $actualPageId,
414 $actualRevId,
415 $actualRevTimestamp
416 ) {
417 $logger = $this->saveParseLogger;
418 $varyMsg = __METHOD__ . ": cannot use prepared output for '{title}'";
419 $context = [ 'title' => $this->title->getPrefixedText() ];
420
421 if ( $out->getFlag( 'vary-revision' ) ) {
422 // If {{PAGEID}} resolved to 0, then that word need to resolve to the actual page ID
423 $logger->info( "$varyMsg (vary-revision)", $context );
424 return true;
425 } elseif (
426 $out->getFlag( 'vary-revision-id' )
427 && $actualRevId !== false
428 && ( $actualRevId === true || $out->getSpeculativeRevIdUsed() !== $actualRevId )
429 ) {
430 $logger->info( "$varyMsg (vary-revision-id and wrong ID)", $context );
431 return true;
432 } elseif (
433 $out->getFlag( 'vary-revision-timestamp' )
434 && $actualRevTimestamp !== false
435 && ( $actualRevTimestamp === true ||
436 $out->getRevisionTimestampUsed() !== $actualRevTimestamp )
437 ) {
438 $logger->info( "$varyMsg (vary-revision-timestamp and wrong timestamp)", $context );
439 return true;
440 } elseif (
441 $out->getFlag( 'vary-page-id' )
442 && $actualPageId !== false
443 && ( $actualPageId === true || $out->getSpeculativePageIdUsed() !== $actualPageId )
444 ) {
445 $logger->info( "$varyMsg (vary-page-id and wrong ID)", $context );
446 return true;
447 } elseif ( $out->getFlag( 'vary-revision-exists' ) ) {
448 // If {{REVISIONID}} resolved to '', it now needs to resolve to '-'.
449 // Note that edit stashing always uses '-', which can be used for both
450 // edit filter checks and canonical parser cache.
451 $logger->info( "$varyMsg (vary-revision-exists)", $context );
452 return true;
453 } elseif (
454 $out->getFlag( 'vary-revision-sha1' ) &&
455 $out->getRevisionUsedSha1Base36() !== $this->revision->getSha1()
456 ) {
457 // If a self-transclusion used the proposed page text, it must match the final
458 // page content after PST transformations and automatically merged edit conflicts
459 $logger->info( "$varyMsg (vary-revision-sha1 with wrong SHA-1)", $context );
460 return true;
461 }
462
463 // NOTE: In the original fix for T135261, the output was discarded if 'vary-user' was
464 // set for a null-edit. The reason was that the original rendering in that case was
465 // targeting the user making the null-edit, not the user who made the original edit,
466 // causing {{REVISIONUSER}} to return the wrong name.
467 // This case is now expected to be handled by the code in RevisionRenderer that
468 // constructs the ParserOptions: For a null-edit, setCurrentRevisionCallback is called
469 // with the old, existing revision.
470 $logger->debug( __METHOD__ . ": reusing prepared output for '{title}'", $context );
471 return false;
472 }
473 }