[MCR] Introduce RevisionRenderer
[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
99 * @param ParserOptions $options
100 * @param callable $combineOutput Callback for combining slot output into revision output.
101 * Signature: function ( RenderedRevision $this ): ParserOutput.
102 * @param int $audience Use RevisionRecord::FOR_PUBLIC, FOR_THIS_USER, or RAW.
103 * @param User|null $forUser Required if $audience is FOR_THIS_USER.
104 */
105 public function __construct(
106 Title $title,
107 RevisionRecord $revision,
108 ParserOptions $options,
109 callable $combineOutput,
110 $audience = RevisionRecord::FOR_PUBLIC,
111 User $forUser = null
112 ) {
113 $this->title = $title;
114 $this->options = $options;
115
116 $this->setRevisionInternal( $revision );
117
118 $this->combineOutput = $combineOutput;
119 $this->saveParseLogger = new NullLogger();
120
121 if ( $audience === RevisionRecord::FOR_THIS_USER && !$forUser ) {
122 throw new InvalidArgumentException(
123 'User must be specified when setting audience to FOR_THIS_USER'
124 );
125 }
126
127 $this->audience = $audience;
128 $this->forUser = $forUser;
129 }
130
131 /**
132 * @param LoggerInterface $saveParseLogger
133 */
134 public function setSaveParseLogger( LoggerInterface $saveParseLogger ) {
135 $this->saveParseLogger = $saveParseLogger;
136 }
137
138 /**
139 * @return bool Whether the revision's content has been hidden from unprivileged users.
140 */
141 public function isContentDeleted() {
142 return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
143 }
144
145 /**
146 * @return RevisionRecord
147 */
148 public function getRevision() {
149 return $this->revision;
150 }
151
152 /**
153 * @return ParserOptions
154 */
155 public function getOptions() {
156 return $this->options;
157 }
158
159 /**
160 * @return ParserOutput
161 */
162 public function getRevisionParserOutput() {
163 if ( !$this->revisionOutput ) {
164 $output = call_user_func( $this->combineOutput, $this );
165
166 Assert::postcondition(
167 $output instanceof ParserOutput,
168 'Callback did not return a ParserOutput object!'
169 );
170
171 $this->revisionOutput = $output;
172 }
173
174 return $this->revisionOutput;
175 }
176
177 /**
178 * @param string $role
179 *
180 * @throws SuppressedDataException if the content is not accessible for the audience
181 * specified in the constructor.
182 * @return ParserOutput
183 */
184 public function getSlotParserOutput( $role ) {
185 // XXX: make html generation optional?
186
187 if ( !isset( $this->slotsOutput[$role] ) ) {
188 $content = $this->revision->getContent( $role, $this->audience, $this->forUser );
189
190 if ( !$content ) {
191 throw new SuppressedDataException(
192 'Access to the content has been suppressed for this audience'
193 );
194 } else {
195 $this->slotsOutput[ $role ] = $content->getParserOutput(
196 $this->title,
197 $this->revision->getId(),
198 $this->options
199 );
200
201 // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput.
202 $this->options->registerWatcher( null );
203 }
204 }
205
206 return $this->slotsOutput[$role];
207 }
208
209 /**
210 * Updates the RevisionRecord after the revision has been saved. This can be used to discard
211 * and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}}
212 * are re-evaluated.
213 *
214 * @note There should be no need to call this for null-edits.
215 *
216 * @param RevisionRecord $rev
217 */
218 public function updateRevision( RevisionRecord $rev ) {
219 if ( $rev->getId() === $this->revision->getId() ) {
220 return;
221 }
222
223 if ( $this->revision->getId() ) {
224 throw new LogicException( 'RenderedRevision already has a revision with ID '
225 . $this->revision->getId(), ', can\'t update to revision with ID ' . $rev->getId() );
226 }
227
228 if ( !$this->revision->getSlots()->hasSameContent( $rev->getSlots() ) ) {
229 throw new LogicException( 'Cannot update to a revision with different content!' );
230 }
231
232 $this->setRevisionInternal( $rev );
233
234 $this->pruneRevisionSensitiveOutput( $this->revision->getId() );
235 }
236
237 /**
238 * Prune any output that depends on the revision ID.
239 *
240 * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
241 * against, or false to not purge on vary-revision-id, or true to purge on
242 * vary-revision-id unconditionally.
243 */
244 private function pruneRevisionSensitiveOutput( $actualRevId ) {
245 if ( $this->revisionOutput ) {
246 if ( $this->outputVariesOnRevisionMetaData( $this->revisionOutput, $actualRevId ) ) {
247 $this->revisionOutput = null;
248 }
249 } else {
250 $this->saveParseLogger->debug( __METHOD__ . ": no prepared revision output...\n" );
251 }
252
253 foreach ( $this->slotsOutput as $role => $output ) {
254 if ( $this->outputVariesOnRevisionMetaData( $output, $actualRevId ) ) {
255 unset( $this->slotsOutput[$role] );
256 }
257 }
258 }
259
260 /**
261 * @param RevisionRecord $revision
262 */
263 private function setRevisionInternal( RevisionRecord $revision ) {
264 $this->revision = $revision;
265
266 // Make sure the parser uses the correct Revision object
267 $title = $this->title;
268 $oldCallback = $this->options->getCurrentRevisionCallback();
269 $this->options->setCurrentRevisionCallback(
270 function ( Title $parserTitle, $parser = false ) use ( $title, $oldCallback ) {
271 if ( $parserTitle->equals( $title ) ) {
272 $legacyRevision = new Revision( $this->revision );
273 return $legacyRevision;
274 } else {
275 return call_user_func( $oldCallback, $parserTitle, $parser );
276 }
277 }
278 );
279 }
280
281 /**
282 * @param ParserOutput $out
283 * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
284 * against, or false to not purge on vary-revision-id, or true to purge on
285 * vary-revision-id unconditionally.
286 * @return bool
287 */
288 private function outputVariesOnRevisionMetaData( ParserOutput $out, $actualRevId ) {
289 $method = __METHOD__;
290
291 if ( $out->getFlag( 'vary-revision' ) ) {
292 // XXX: Would be just keep the output if the speculative revision ID was correct,
293 // but that can go wrong for some edge cases, like {{PAGEID}} during page creation.
294 // For that specific case, it would perhaps nice to have a vary-page flag.
295 $this->saveParseLogger->info(
296 "$method: Prepared output has vary-revision...\n"
297 );
298 return true;
299 } elseif ( $out->getFlag( 'vary-revision-id' )
300 && $actualRevId !== false
301 && ( $actualRevId === true || $out->getSpeculativeRevIdUsed() !== $actualRevId )
302 ) {
303 $this->saveParseLogger->info(
304 "$method: Prepared output has vary-revision-id with wrong ID...\n"
305 );
306 return true;
307 } else {
308 // NOTE: In the original fix for T135261, the output was discarded if 'vary-user' was
309 // set for a null-edit. The reason was that the original rendering in that case was
310 // targeting the user making the null-edit, not the user who made the original edit,
311 // causing {{REVISIONUSER}} to return the wrong name.
312 // This case is now expected to be handled by the code in RevisionRenderer that
313 // constructs the ParserOptions: For a null-edit, setCurrentRevisionCallback is called
314 // with the old, existing revision.
315
316 wfDebug( "$method: Keeping prepared output...\n" );
317 return false;
318 }
319 }
320
321 }