Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.50% covered (warning)
84.50%
109 / 129
53.85% covered (warning)
53.85%
7 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
RenderedRevision
84.50% covered (warning)
84.50%
109 / 129
53.85% covered (warning)
53.85%
7 / 13
66.27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 setSaveParseLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isContentDeleted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevision
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRevisionParserOutput
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getRevisionParserOutput
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getSlotParserOutput
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
8.30
 getSlotParserOutputUncached
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 updateRevision
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
9
 pruneRevisionSensitiveOutput
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 setRevisionInternal
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 outputVariesOnRevisionMetaData
67.74% covered (warning)
67.74%
21 / 31
0.00% covered (danger)
0.00%
0 / 1
26.70
1<?php
2/**
3 * This file is part of MediaWiki.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Revision;
10
11use InvalidArgumentException;
12use LogicException;
13use MediaWiki\Content\Content;
14use MediaWiki\Content\Renderer\ContentRenderer;
15use MediaWiki\Page\PageReference;
16use MediaWiki\Parser\ParserOptions;
17use MediaWiki\Parser\ParserOutput;
18use MediaWiki\Parser\ParserOutputFlags;
19use MediaWiki\Permissions\Authority;
20use Psr\Log\LoggerInterface;
21use Psr\Log\NullLogger;
22use Wikimedia\Assert\Assert;
23
24/**
25 * RenderedRevision represents the rendered representation of a revision. It acts as a lazy provider
26 * of ParserOutput objects for the revision's individual slots, as well as a combined ParserOutput
27 * of all slots.
28 *
29 * @since 1.32
30 */
31class RenderedRevision implements SlotRenderingProvider {
32
33    /** @var RevisionRecord */
34    private $revision;
35
36    /**
37     * @var ParserOptions
38     */
39    private $options;
40
41    /**
42     * @var int Audience to check when accessing content.
43     */
44    private $audience = RevisionRecord::FOR_PUBLIC;
45
46    /**
47     * @var Authority|null The user to use for audience checks during content access.
48     */
49    private $performer = null;
50
51    /**
52     * @var ParserOutput|null The combined ParserOutput for the revision,
53     *      initialized lazily by getRevisionParserOutput().
54     */
55    private $revisionOutput = null;
56
57    /**
58     * @var ParserOutput[] The ParserOutput for each slot,
59     *      initialized lazily by getSlotParserOutput().
60     */
61    private $slotsOutput = [];
62
63    /**
64     * @var callable Callback for combining slot output into revision output.
65     *      Signature: function ( RenderedRevision $this, array $hints ): ParserOutput.
66     */
67    private $combineOutput;
68
69    /**
70     * @var LoggerInterface For profiling ParserOutput re-use.
71     */
72    private $saveParseLogger;
73
74    /**
75     * @var ContentRenderer Service to render content.
76     */
77    private $contentRenderer;
78
79    /**
80     * @note Application logic should not instantiate RenderedRevision instances directly,
81     * but should use a RevisionRenderer instead.
82     *
83     * @param RevisionRecord $revision The revision to render. The content for rendering will be
84     *        taken from this RevisionRecord. However, if the RevisionRecord is not complete
85     *        according isReadyForInsertion(), but a revision ID is known, the parser may load
86     *        the revision from the database if it needs revision meta data to handle magic
87     *        words like {{REVISIONUSER}}.
88     * @param ParserOptions $options
89     * @param ContentRenderer $contentRenderer
90     * @param callable $combineOutput Callback for combining slot output into revision output.
91     *        Signature: function ( RenderedRevision $this, array $hints ): ParserOutput.
92     * @param int $audience Use RevisionRecord::FOR_PUBLIC, FOR_THIS_USER, or RAW.
93     * @param Authority|null $performer Required if $audience is FOR_THIS_USER.
94     */
95    public function __construct(
96        RevisionRecord $revision,
97        ParserOptions $options,
98        ContentRenderer $contentRenderer,
99        callable $combineOutput,
100        $audience = RevisionRecord::FOR_PUBLIC,
101        ?Authority $performer = null
102    ) {
103        $this->options = $options;
104
105        $this->setRevisionInternal( $revision );
106
107        $this->contentRenderer = $contentRenderer;
108        $this->combineOutput = $combineOutput;
109        $this->saveParseLogger = new NullLogger();
110
111        if ( $audience === RevisionRecord::FOR_THIS_USER && !$performer ) {
112            throw new InvalidArgumentException(
113                'User must be specified when setting audience to FOR_THIS_USER'
114            );
115        }
116
117        $this->audience = $audience;
118        $this->performer = $performer;
119    }
120
121    public function setSaveParseLogger( LoggerInterface $saveParseLogger ) {
122        $this->saveParseLogger = $saveParseLogger;
123    }
124
125    /**
126     * @return bool Whether the revision's content has been hidden from unprivileged users.
127     */
128    public function isContentDeleted() {
129        return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
130    }
131
132    /**
133     * @return RevisionRecord
134     */
135    public function getRevision() {
136        return $this->revision;
137    }
138
139    /**
140     * @return ParserOptions
141     */
142    public function getOptions() {
143        return $this->options;
144    }
145
146    /**
147     * Sets a ParserOutput to be returned by getRevisionParserOutput().
148     *
149     * @note For internal use by RevisionRenderer only! This method may be modified
150     * or removed without notice per the deprecation policy.
151     *
152     * @internal
153     *
154     * @param ParserOutput $output
155     */
156    public function setRevisionParserOutput( ParserOutput $output ) {
157        $this->revisionOutput = $output;
158
159        // If there is only one slot, we assume that the combined output is identical
160        // with the main slot's output. This is intended to prevent a redundant re-parse of
161        // the content in case getSlotParserOutput( SlotRecord::MAIN ) is called, for instance
162        // from ContentHandler::getSecondaryDataUpdates.
163        if ( $this->revision->getSlotRoles() === [ SlotRecord::MAIN ] ) {
164            $this->slotsOutput[ SlotRecord::MAIN ] = $output;
165        }
166    }
167
168    /**
169     * @param array $hints Hints given as an associative array. Known keys:
170     *   - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
171     *     to just meta-data). Default is to generate HTML.
172     * @phan-param array{generate-html?:bool} $hints
173     *
174     * @return ParserOutput
175     */
176    public function getRevisionParserOutput( array $hints = [] ) {
177        $withHtml = $hints['generate-html'] ?? true;
178
179        if ( !$this->revisionOutput
180            || ( $withHtml && !$this->revisionOutput->hasText() )
181        ) {
182            $output = ( $this->combineOutput )( $this, $hints );
183
184            Assert::postcondition(
185                $output instanceof ParserOutput,
186                'Callback did not return a ParserOutput object!'
187            );
188
189            $this->revisionOutput = $output;
190        }
191
192        return $this->revisionOutput;
193    }
194
195    /**
196     * @param string $role
197     * @param array $hints Hints given as an associative array. Known keys:
198     *   - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
199     *     to just meta-data). Default is to generate HTML.
200     *   - 'previous-output' => ?ParserOutput: An optional "previously parsed"
201     *     version of this slot; used to allow Parsoid selective updates.
202     * @phan-param array{generate-html?:bool,previous-output?:?ParserOutput} $hints
203     *
204     * @throws SuppressedDataException if the content is not accessible for the audience
205     *   specified in the constructor.
206     * @throws BadRevisionException
207     * @throws RevisionAccessException
208     * @return ParserOutput
209     */
210    public function getSlotParserOutput( $role, array $hints = [] ) {
211        $withHtml = $hints['generate-html'] ?? true;
212
213        if ( !isset( $this->slotsOutput[ $role ] )
214            || ( $withHtml && !$this->slotsOutput[ $role ]->hasText() )
215        ) {
216            $content = $this->revision->getContentOrThrow( $role, $this->audience, $this->performer );
217
218            // XXX: allow SlotRoleHandler to control the ParserOutput?
219            $output = $this->getSlotParserOutputUncached( $content, $hints );
220
221            if ( $withHtml && !$output->hasText() ) {
222                throw new LogicException(
223                    'HTML generation was requested, but '
224                    . get_class( $content )
225                    . ' that passed to '
226                    . 'ContentRenderer::getParserOutput() returns a ParserOutput with no text set.'
227                );
228            }
229
230            // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput.
231            $this->options->registerWatcher( null );
232
233            $this->slotsOutput[ $role ] = $output;
234        }
235
236        return $this->slotsOutput[$role];
237    }
238
239    /**
240     * @note This method exists to make duplicate parses easier to see during profiling
241     * @param Content $content
242     * @param array{generate-html?:bool,previous-output?:?ParserOutput} $hints
243     * @return ParserOutput
244     */
245    private function getSlotParserOutputUncached( Content $content, array $hints ): ParserOutput {
246        return $this->contentRenderer->getParserOutput(
247            $content,
248            $this->revision->getPage(),
249            $this->revision,
250            $this->options,
251            $hints
252        );
253    }
254
255    /**
256     * Updates the RevisionRecord after the revision has been saved. This can be used to discard
257     * and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}}
258     * are re-evaluated.
259     *
260     * @note There should be no need to call this for null-edits.
261     *
262     * @param RevisionRecord $rev
263     */
264    public function updateRevision( RevisionRecord $rev ) {
265        if ( $rev->getId() === $this->revision->getId() ) {
266            return;
267        }
268
269        if ( $this->revision->getId() ) {
270            throw new LogicException( 'RenderedRevision already has a revision with ID '
271                . $this->revision->getId() . ', can\'t update to revision with ID ' . $rev->getId() );
272        }
273
274        if ( !$this->revision->getSlots()->hasSameContent( $rev->getSlots() ) ) {
275            throw new LogicException( 'Cannot update to a revision with different content!' );
276        }
277
278        $this->setRevisionInternal( $rev );
279
280        $this->pruneRevisionSensitiveOutput(
281            $this->revision->getPageId(),
282            $this->revision->getId(),
283            $this->revision->getTimestamp()
284        );
285
286        // T358708: Update the cache revision ID on any ParserOutput that was
287        // kept (not pruned) by pruneRevisionSensitiveOutput(). These outputs
288        // were originally rendered with a MutableRevisionRecord that had no
289        // revision ID, so ContentRenderer did not set the cacheRevisionId.
290        // Without this, ParserCache::save() would see a mismatch between the
291        // actual revision ID and the null cacheRevisionId, causing a
292        // high-volume "Inconsistent revision ID" warning.
293        $revId = $this->revision->getId();
294        if ( $revId ) {
295            if ( $this->revisionOutput !== null
296                && $this->revisionOutput->getCacheRevisionId() === null
297            ) {
298                $this->revisionOutput->setCacheRevisionId( $revId );
299            }
300            foreach ( $this->slotsOutput as $output ) {
301                if ( $output->getCacheRevisionId() === null ) {
302                    $output->setCacheRevisionId( $revId );
303                }
304            }
305        }
306    }
307
308    /**
309     * Prune any output that depends on the revision ID.
310     *
311     * @param int|bool $actualPageId The actual page id, to check the used speculative page ID
312     *        against; false, to not purge on vary-page-id; true, to purge on vary-page-id
313     *        unconditionally.
314     * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
315     *        against,; false, to not purge on vary-revision-id; true, to purge on
316     *        vary-revision-id unconditionally.
317     * @param string|bool $actualRevTimestamp The actual rev timestamp, to check against the
318     *        parser output revision timestamp; false, to not purge on vary-revision-timestamp;
319     *        true, to purge on vary-revision-timestamp unconditionally.
320     */
321    private function pruneRevisionSensitiveOutput(
322        $actualPageId,
323        $actualRevId,
324        $actualRevTimestamp
325    ) {
326        if ( $this->revisionOutput ) {
327            if ( $this->outputVariesOnRevisionMetaData(
328                $this->revisionOutput,
329                $actualPageId,
330                $actualRevId,
331                $actualRevTimestamp
332            ) ) {
333                $this->revisionOutput = null;
334            }
335        } else {
336            $this->saveParseLogger->debug( __METHOD__ . ": no prepared revision output" );
337        }
338
339        foreach ( $this->slotsOutput as $role => $output ) {
340            if ( $this->outputVariesOnRevisionMetaData(
341                $output,
342                $actualPageId,
343                $actualRevId,
344                $actualRevTimestamp
345            ) ) {
346                unset( $this->slotsOutput[$role] );
347            }
348        }
349    }
350
351    private function setRevisionInternal( RevisionRecord $revision ) {
352        $this->revision = $revision;
353
354        // Force the parser to use  $this->revision to resolve magic words like {{REVISIONUSER}}
355        // if the revision is either known to be complete, or it doesn't have a revision ID set.
356        // If it's incomplete and we have a revision ID, the parser can do better by loading
357        // the revision from the database if needed to handle a magic word.
358        //
359        // The following considerations inform the logic described above:
360        //
361        // 1) If we have a saved revision already loaded, we want the parser to use it, instead of
362        // loading it again.
363        //
364        // 2) If the revision is a fake that wraps some kind of synthetic content, such as an
365        // error message from Article, it should be used directly and things like {{REVISIONUSER}}
366        // should not expected to work, since there may not even be an actual revision to
367        // refer to.
368        //
369        // 3) If the revision is a fake constructed around a page, a Content object, and
370        // a revision ID, to provide backwards compatibility to code that has access to those
371        // but not to a complete RevisionRecord for rendering, then we want the Parser to
372        // load the actual revision from the database when it encounters a magic word like
373        // {{REVISIONUSER}}, but we don't want to load that revision ahead of time just in case.
374        //
375        // 4) Previewing an edit to a template should use the submitted unsaved
376        // MutableRevisionRecord for self-transclusions in the template's documentation (see T7278).
377        // That revision would be complete except for the ID field.
378        //
379        // 5) Pre-save transform would provide a RevisionRecord that has all meta-data but is
380        // incomplete due to not yet having content set. However, since it doesn't have a revision
381        // ID either, the below code would still force it to be used, allowing
382        // {{subst::REVISIONUSER}} to function as expected.
383
384        if ( $this->revision->isReadyForInsertion() || !$this->revision->getId() ) {
385            $oldCallback = $this->options->getCurrentRevisionRecordCallback();
386            $this->options->setCurrentRevisionRecordCallback(
387                function ( PageReference $parserPage, $parser = null ) use ( $oldCallback ) {
388                    if ( $this->revision->getPage()->isSamePageAs( $parserPage ) ) {
389                        return $this->revision;
390                    } else {
391                        return $oldCallback( $parserPage, $parser );
392                    }
393                }
394            );
395        }
396    }
397
398    /**
399     * @param ParserOutput $parserOutput
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 $parserOutput,
413        $actualPageId,
414        $actualRevId,
415        $actualRevTimestamp
416    ) {
417        $logger = $this->saveParseLogger;
418        $varyMsg = __METHOD__ . ": cannot use prepared output for '{title}'";
419        $context = [ 'title' => (string)$this->revision->getPage() ];
420
421        if ( $parserOutput->getOutputFlag( ParserOutputFlags::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            $parserOutput->getOutputFlag( ParserOutputFlags::VARY_REVISION_ID )
427            && $actualRevId !== false
428            && ( $actualRevId === true || $parserOutput->getSpeculativeRevIdUsed() !== $actualRevId )
429        ) {
430            $logger->info( "$varyMsg (vary-revision-id and wrong ID)", $context );
431            return true;
432        } elseif (
433            $parserOutput->getOutputFlag( ParserOutputFlags::VARY_REVISION_TIMESTAMP )
434            && $actualRevTimestamp !== false
435            && ( $actualRevTimestamp === true ||
436                $parserOutput->getRevisionTimestampUsed() !== $actualRevTimestamp )
437        ) {
438            $logger->info( "$varyMsg (vary-revision-timestamp and wrong timestamp)", $context );
439            return true;
440        } elseif (
441            $parserOutput->getOutputFlag( ParserOutputFlags::VARY_PAGE_ID )
442            && $actualPageId !== false
443            && ( $actualPageId === true || $parserOutput->getSpeculativePageIdUsed() !== $actualPageId )
444        ) {
445            $logger->info( "$varyMsg (vary-page-id and wrong ID)", $context );
446            return true;
447        } elseif ( $parserOutput->getOutputFlag( ParserOutputFlags::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            $parserOutput->getOutputFlag( ParserOutputFlags::VARY_REVISION_SHA1 ) &&
455            $parserOutput->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 ParserOutputFlags::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, setCurrentRevisionRecordCallback is
469        // called with the old, existing revision.
470        $logger->debug( __METHOD__ . ": reusing prepared output for '{title}'", $context );
471        return false;
472    }
473}