resources: Strip '$' and 'mw' from file closures
[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 MediaWiki\Storage\RevisionRecord;
28 use MediaWiki\Storage\SuppressedDataException;
29 use ParserOptions;
30 use ParserOutput;
31 use Psr\Log\LoggerInterface;
32 use Psr\Log\NullLogger;
33 use Revision;
34 use Title;
35 use User;
36 use 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 */
45 class RenderedRevision {
46
47 /**
48 * @var Title
49 */
50 private $title;
51
52 /** @var RevisionRecord */
53 private $revision;
54
55 /**
56 * @var ParserOptions
57 */
58 private $options;
59
60 /**
61 * @var int Audience to check when accessing content.
62 */
63 private $audience = RevisionRecord::FOR_PUBLIC;
64
65 /**
66 * @var User|null The user to use for audience checks during content access.
67 */
68 private $forUser = null;
69
70 /**
71 * @var ParserOutput|null The combined ParserOutput for the revision,
72 * initialized lazily by getRevisionParserOutput().
73 */
74 private $revisionOutput = null;
75
76 /**
77 * @var ParserOutput[] The ParserOutput for each slot,
78 * initialized lazily by getSlotParserOutput().
79 */
80 private $slotsOutput = [];
81
82 /**
83 * @var callable Callback for combining slot output into revision output.
84 * Signature: function ( RenderedRevision $this ): ParserOutput.
85 */
86 private $combineOutput;
87
88 /**
89 * @var LoggerInterface For profiling ParserOutput re-use.
90 */
91 private $saveParseLogger;
92
93 /**
94 * @note Application logic should not instantiate RenderedRevision instances directly,
95 * but should use a RevisionRenderer instead.
96 *
97 * @param Title $title
98 * @param RevisionRecord $revision The revision to render. The content for rendering will be
99 * taken from this RevisionRecord. However, if the RevisionRecord is not complete
100 * according isReadyForInsertion(), but a revision ID is known, the parser may load
101 * the revision from the database if it needs revision meta data to handle magic
102 * words like {{REVISIONUSER}}.
103 * @param ParserOptions $options
104 * @param callable $combineOutput Callback for combining slot output into revision output.
105 * Signature: function ( RenderedRevision $this ): ParserOutput.
106 * @param int $audience Use RevisionRecord::FOR_PUBLIC, FOR_THIS_USER, or RAW.
107 * @param User|null $forUser Required if $audience is FOR_THIS_USER.
108 */
109 public function __construct(
110 Title $title,
111 RevisionRecord $revision,
112 ParserOptions $options,
113 callable $combineOutput,
114 $audience = RevisionRecord::FOR_PUBLIC,
115 User $forUser = null
116 ) {
117 $this->title = $title;
118 $this->options = $options;
119
120 $this->setRevisionInternal( $revision );
121
122 $this->combineOutput = $combineOutput;
123 $this->saveParseLogger = new NullLogger();
124
125 if ( $audience === RevisionRecord::FOR_THIS_USER && !$forUser ) {
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->forUser = $forUser;
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 * @param array $hints Hints given as an associative array. Known keys:
165 * - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
166 * to just meta-data). Default is to generate HTML.
167 *
168 * @return ParserOutput
169 */
170 public function getRevisionParserOutput( array $hints = [] ) {
171 $withHtml = $hints['generate-html'] ?? true;
172
173 if ( !$this->revisionOutput
174 || ( $withHtml && !$this->revisionOutput->hasText() )
175 ) {
176 $output = call_user_func( $this->combineOutput, $this, $hints );
177
178 Assert::postcondition(
179 $output instanceof ParserOutput,
180 'Callback did not return a ParserOutput object!'
181 );
182
183 $this->revisionOutput = $output;
184 }
185
186 return $this->revisionOutput;
187 }
188
189 /**
190 * @param string $role
191 * @param array $hints Hints given as an associative array. Known keys:
192 * - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
193 * to just meta-data). Default is to generate HTML.
194 *
195 * @throws SuppressedDataException if the content is not accessible for the audience
196 * specified in the constructor.
197 * @return ParserOutput
198 */
199 public function getSlotParserOutput( $role, array $hints = [] ) {
200 $withHtml = $hints['generate-html'] ?? true;
201
202 if ( !isset( $this->slotsOutput[ $role ] )
203 || ( $withHtml && !$this->slotsOutput[ $role ]->hasText() )
204 ) {
205 $content = $this->revision->getContent( $role, $this->audience, $this->forUser );
206
207 if ( !$content ) {
208 throw new SuppressedDataException(
209 'Access to the content has been suppressed for this audience'
210 );
211 } else {
212 $output = $content->getParserOutput(
213 $this->title,
214 $this->revision->getId(),
215 $this->options,
216 $withHtml
217 );
218
219 if ( $withHtml && !$output->hasText() ) {
220 throw new LogicException(
221 'HTML generation was requested, but '
222 . get_class( $content )
223 . '::getParserOutput() returns a ParserOutput with no text set.'
224 );
225 }
226
227 // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput.
228 $this->options->registerWatcher( null );
229 }
230
231 $this->slotsOutput[ $role ] = $output;
232 }
233
234 return $this->slotsOutput[$role];
235 }
236
237 /**
238 * Updates the RevisionRecord after the revision has been saved. This can be used to discard
239 * and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}}
240 * are re-evaluated.
241 *
242 * @note There should be no need to call this for null-edits.
243 *
244 * @param RevisionRecord $rev
245 */
246 public function updateRevision( RevisionRecord $rev ) {
247 if ( $rev->getId() === $this->revision->getId() ) {
248 return;
249 }
250
251 if ( $this->revision->getId() ) {
252 throw new LogicException( 'RenderedRevision already has a revision with ID '
253 . $this->revision->getId(), ', can\'t update to revision with ID ' . $rev->getId() );
254 }
255
256 if ( !$this->revision->getSlots()->hasSameContent( $rev->getSlots() ) ) {
257 throw new LogicException( 'Cannot update to a revision with different content!' );
258 }
259
260 $this->setRevisionInternal( $rev );
261
262 $this->pruneRevisionSensitiveOutput( $this->revision->getId() );
263 }
264
265 /**
266 * Prune any output that depends on the revision ID.
267 *
268 * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
269 * against, or false to not purge on vary-revision-id, or true to purge on
270 * vary-revision-id unconditionally.
271 */
272 private function pruneRevisionSensitiveOutput( $actualRevId ) {
273 if ( $this->revisionOutput ) {
274 if ( $this->outputVariesOnRevisionMetaData( $this->revisionOutput, $actualRevId ) ) {
275 $this->revisionOutput = null;
276 }
277 } else {
278 $this->saveParseLogger->debug( __METHOD__ . ": no prepared revision output...\n" );
279 }
280
281 foreach ( $this->slotsOutput as $role => $output ) {
282 if ( $this->outputVariesOnRevisionMetaData( $output, $actualRevId ) ) {
283 unset( $this->slotsOutput[$role] );
284 }
285 }
286 }
287
288 /**
289 * @param RevisionRecord $revision
290 */
291 private function setRevisionInternal( RevisionRecord $revision ) {
292 $this->revision = $revision;
293
294 // Force the parser to use $this->revision to resolve magic words like {{REVISIONUSER}}
295 // if the revision is either known to be complete, or it doesn't have a revision ID set.
296 // If it's incomplete and we have a revision ID, the parser can do better by loading
297 // the revision from the database if needed to handle a magic word.
298 //
299 // The following considerations inform the logic described above:
300 //
301 // 1) If we have a saved revision already loaded, we want the parser to use it, instead of
302 // loading it again.
303 //
304 // 2) If the revision is a fake that wraps some kind of synthetic content, such as an
305 // error message from Article, it should be used directly and things like {{REVISIONUSER}}
306 // should not expected to work, since there may not even be an actual revision to
307 // refer to.
308 //
309 // 3) If the revision is a fake constructed around a Title, a Content object, and
310 // a revision ID, to provide backwards compatibility to code that has access to those
311 // but not to a complete RevisionRecord for rendering, then we want the Parser to
312 // load the actual revision from the database when it encounters a magic word like
313 // {{REVISIONUSER}}, but we don't want to load that revision ahead of time just in case.
314 //
315 // 4) Previewing an edit to a template should use the submitted unsaved
316 // MutableRevisionRecord for self-transclusions in the template's documentation (see T7278).
317 // That revision would be complete except for the ID field.
318 //
319 // 5) Pre-save transform would provide a RevisionRecord that has all meta-data but is
320 // incomplete due to not yet having content set. However, since it doesn't have a revision
321 // ID either, the below code would still force it to be used, allowing
322 // {{subst::REVISIONUSER}} to function as expected.
323
324 if ( $this->revision->isReadyForInsertion() || !$this->revision->getId() ) {
325 $title = $this->title;
326 $oldCallback = $this->options->getCurrentRevisionCallback();
327 $this->options->setCurrentRevisionCallback(
328 function ( Title $parserTitle, $parser = false ) use ( $title, $oldCallback ) {
329 if ( $title->equals( $parserTitle ) ) {
330 $legacyRevision = new Revision( $this->revision );
331 return $legacyRevision;
332 } else {
333 return call_user_func( $oldCallback, $parserTitle, $parser );
334 }
335 }
336 );
337 }
338 }
339
340 /**
341 * @param ParserOutput $out
342 * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
343 * against, or false to not purge on vary-revision-id, or true to purge on
344 * vary-revision-id unconditionally.
345 * @return bool
346 */
347 private function outputVariesOnRevisionMetaData( ParserOutput $out, $actualRevId ) {
348 $method = __METHOD__;
349
350 if ( $out->getFlag( 'vary-revision' ) ) {
351 // XXX: Would be just keep the output if the speculative revision ID was correct,
352 // but that can go wrong for some edge cases, like {{PAGEID}} during page creation.
353 // For that specific case, it would perhaps nice to have a vary-page flag.
354 $this->saveParseLogger->info(
355 "$method: Prepared output has vary-revision...\n"
356 );
357 return true;
358 } elseif ( $out->getFlag( 'vary-revision-id' )
359 && $actualRevId !== false
360 && ( $actualRevId === true || $out->getSpeculativeRevIdUsed() !== $actualRevId )
361 ) {
362 $this->saveParseLogger->info(
363 "$method: Prepared output has vary-revision-id with wrong ID...\n"
364 );
365 return true;
366 } else {
367 // NOTE: In the original fix for T135261, the output was discarded if 'vary-user' was
368 // set for a null-edit. The reason was that the original rendering in that case was
369 // targeting the user making the null-edit, not the user who made the original edit,
370 // causing {{REVISIONUSER}} to return the wrong name.
371 // This case is now expected to be handled by the code in RevisionRenderer that
372 // constructs the ParserOptions: For a null-edit, setCurrentRevisionCallback is called
373 // with the old, existing revision.
374
375 wfDebug( "$method: Keeping prepared output...\n" );
376 return false;
377 }
378 }
379
380 }