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