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