Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.47% covered (warning)
71.47%
243 / 340
37.93% covered (danger)
37.93%
11 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
HtmlOutputRendererHelper
71.47% covered (warning)
71.47%
243 / 340
37.93% covered (danger)
37.93%
11 / 29
384.89
0.00% covered (danger)
0.00%
0 / 1
 __construct
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 setFlavor
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getFlavor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setOutputProfileVersion
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 setStashingEnabled
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 setRevision
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 setContent
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setContentSource
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 setPageLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 init
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 initInternal
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 setVariantConversionLanguage
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getAcceptedTargetLanguage
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getHtml
72.34% covered (warning)
72.34%
34 / 47
0.00% covered (danger)
0.00%
0 / 1
8.04
 getETag
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 isLatest
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 getLastModified
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getParamSettings
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultPageLanguage
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getParserOutput
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
10.03
 getHtmlOutputContentLanguage
21.43% covered (danger)
21.43%
3 / 14
0.00% covered (danger)
0.00%
0 / 1
7.37
 putHeaders
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 getPageBundle
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getRevisionId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 stripParsoidSectionTags
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getPageRecord
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 getParserOutputInternal
65.62% covered (warning)
65.62%
42 / 64
0.00% covered (danger)
0.00%
0 / 1
33.66
 parseUncacheable
63.16% covered (warning)
63.16%
12 / 19
0.00% covered (danger)
0.00%
0 / 1
7.80
 isParsoidContent
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6namespace MediaWiki\Rest\Handler\Helper;
7
8use InvalidArgumentException;
9use MediaWiki\Content\Content;
10use MediaWiki\Content\IContentHandlerFactory;
11use MediaWiki\Edit\ParsoidOutputStash;
12use MediaWiki\Edit\ParsoidRenderID;
13use MediaWiki\Edit\SelserContext;
14use MediaWiki\Exception\HttpError;
15use MediaWiki\Exception\MWUnknownContentModelException;
16use MediaWiki\Language\LanguageCode;
17use MediaWiki\Languages\LanguageFactory;
18use MediaWiki\Logger\LoggerFactory;
19use MediaWiki\MainConfigNames;
20use MediaWiki\Page\PageIdentity;
21use MediaWiki\Page\PageLookup;
22use MediaWiki\Page\PageRecord;
23use MediaWiki\Page\ParserOutputAccess;
24use MediaWiki\Parser\ContentHolder;
25use MediaWiki\Parser\ParserOptions;
26use MediaWiki\Parser\ParserOutput;
27use MediaWiki\Parser\Parsoid\Config\SiteConfig as ParsoidSiteConfig;
28use MediaWiki\Parser\Parsoid\HtmlTransformFactory;
29use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
30use MediaWiki\Permissions\Authority;
31use MediaWiki\Rest\Handler;
32use MediaWiki\Rest\HttpException;
33use MediaWiki\Rest\LocalizedHttpException;
34use MediaWiki\Rest\ResponseInterface;
35use MediaWiki\Revision\MutableRevisionRecord;
36use MediaWiki\Revision\RevisionAccessException;
37use MediaWiki\Revision\RevisionLookup;
38use MediaWiki\Revision\RevisionRecord;
39use MediaWiki\Revision\RevisionRenderer;
40use MediaWiki\Revision\SlotRecord;
41use MediaWiki\Status\Status;
42use MediaWiki\Title\Title;
43use RuntimeException;
44use Wikimedia\Assert\Assert;
45use Wikimedia\Bcp47Code\Bcp47Code;
46use Wikimedia\Bcp47Code\Bcp47CodeValue;
47use Wikimedia\Message\MessageValue;
48use Wikimedia\ParamValidator\ParamValidator;
49use Wikimedia\Parsoid\Core\ClientError;
50use Wikimedia\Parsoid\Core\HtmlPageBundle;
51use Wikimedia\Parsoid\Core\ResourceLimitExceededException;
52use Wikimedia\Parsoid\DOM\DocumentFragment;
53use Wikimedia\Parsoid\DOM\Element;
54use Wikimedia\Parsoid\Parsoid;
55use Wikimedia\Parsoid\Utils\DOMUtils;
56use Wikimedia\Parsoid\Utils\WTUtils;
57use Wikimedia\Stats\StatsFactory;
58
59/**
60 * Helper for getting output of a given wikitext page rendered by parsoid.
61 *
62 * @since 1.36
63 *
64 * @unstable Pending consolidation of the Parsoid extension with core code.
65 */
66class HtmlOutputRendererHelper implements HtmlOutputHelper {
67    use RestAuthorizeTrait;
68    use RestStatusTrait;
69
70    /**
71     * @internal
72     */
73    public const CONSTRUCTOR_OPTIONS = [
74        MainConfigNames::ParsoidCacheConfig
75    ];
76
77    private const OUTPUT_FLAVORS = [ 'view', 'stash', 'fragment', 'edit' ];
78
79    /** @var PageIdentity|null */
80    private $page = null;
81
82    /** @var RevisionRecord|int|null */
83    private $revisionOrId = null;
84
85    /** @var Bcp47Code|null */
86    private $pageLanguage = null;
87
88    /** @var ?string One of the flavors from OUTPUT_FLAVORS */
89    private $flavor = null;
90
91    /** @var bool */
92    private $stash = false;
93
94    /** @var Authority */
95    private $authority;
96
97    /** @var ParserOutput */
98    private $parserOutput;
99
100    /** @var ParserOutput */
101    private $processedParserOutput;
102
103    /** @var ?Bcp47Code */
104    private $sourceLanguage = null;
105
106    /** @var ?Bcp47Code */
107    private $targetLanguage = null;
108
109    /**
110     * Should we ignore mismatches between $page and the page that $revision belongs to?
111     * Usually happens because of page moves. This should be set to true only for internal API calls.
112     */
113    private bool $lenientRevHandling = false;
114
115    /**
116     * @see the $options parameter on Parsoid::wikitext2html
117     * @var array
118     */
119    private $parsoidOptions = [];
120
121    private ?ParserOptions $parserOptions = null;
122
123    /**
124     * Whether the result can be cached in the parser cache and the web cache.
125     * Set to false when bespoke options are set.
126     *
127     * @var bool
128     */
129    private $isCacheable = true;
130
131    private ParsoidOutputStash $parsoidOutputStash;
132    private StatsFactory $statsFactory;
133    private ParserOutputAccess $parserOutputAccess;
134    private PageLookup $pageLookup;
135    private RevisionLookup $revisionLookup;
136    private RevisionRenderer $revisionRenderer;
137    private ParsoidSiteConfig $parsoidSiteConfig;
138    private HtmlTransformFactory $htmlTransformFactory;
139    private IContentHandlerFactory $contentHandlerFactory;
140    private LanguageFactory $languageFactory;
141
142    /**
143     * @param ParsoidOutputStash $parsoidOutputStash
144     * @param StatsFactory $statsFactory
145     * @param ParserOutputAccess $parserOutputAccess
146     * @param PageLookup $pageLookup
147     * @param RevisionLookup $revisionLookup
148     * @param RevisionRenderer $revisionRenderer
149     * @param ParsoidSiteConfig $parsoidSiteConfig
150     * @param HtmlTransformFactory $htmlTransformFactory
151     * @param IContentHandlerFactory $contentHandlerFactory
152     * @param LanguageFactory $languageFactory
153     * @param PageIdentity|null $page
154     * @param array $parameters
155     * @param Authority|null $authority
156     * @param RevisionRecord|int|null $revision
157     * @param bool $lenientRevHandling Should we ignore mismatches between
158     *    $page and the page that $revision belongs to? Usually happens
159     *    because of page moves. This should be set to true only for
160     *    internal API calls.
161     * @param ParserOptions|null $parserOptions
162     * @note Since 1.43, setting $page and $authority arguments to null
163     *    has been deprecated.
164     */
165    public function __construct(
166        ParsoidOutputStash $parsoidOutputStash,
167        StatsFactory $statsFactory,
168        ParserOutputAccess $parserOutputAccess,
169        PageLookup $pageLookup,
170        RevisionLookup $revisionLookup,
171        RevisionRenderer $revisionRenderer,
172        ParsoidSiteConfig $parsoidSiteConfig,
173        HtmlTransformFactory $htmlTransformFactory,
174        IContentHandlerFactory $contentHandlerFactory,
175        LanguageFactory $languageFactory,
176        ?PageIdentity $page = null,
177        array $parameters = [],
178        ?Authority $authority = null,
179        $revision = null,
180        bool $lenientRevHandling = false,
181        ?ParserOptions $parserOptions = null
182    ) {
183        $this->parsoidOutputStash = $parsoidOutputStash;
184        $this->statsFactory = $statsFactory;
185        $this->parserOutputAccess = $parserOutputAccess;
186        $this->pageLookup = $pageLookup;
187        $this->revisionLookup = $revisionLookup;
188        $this->revisionRenderer = $revisionRenderer;
189        $this->parsoidSiteConfig = $parsoidSiteConfig;
190        $this->htmlTransformFactory = $htmlTransformFactory;
191        $this->contentHandlerFactory = $contentHandlerFactory;
192        $this->languageFactory = $languageFactory;
193        $this->lenientRevHandling = $lenientRevHandling;
194        $this->parserOptions = $parserOptions;
195        if ( $page === null || $authority === null ) {
196            // Constructing without $page and $authority parameters
197            // is deprecated since 1.43.
198            wfDeprecated( __METHOD__ . ' without $page or $authority', '1.43' );
199        } else {
200            $this->initInternal( $page, $parameters, $authority, $revision );
201        }
202    }
203
204    /**
205     * Sets the given flavor to use for Wikitext -> HTML transformations.
206     *
207     * Flavors may influence parser options, parsoid options, and DOM transformations.
208     * They will be reflected by the ETag returned by getETag().
209     *
210     * @note This method should not be called if stashing mode is enabled.
211     * @see setStashingEnabled
212     * @see getFlavor()
213     *
214     * @param string $flavor
215     *
216     * @return void
217     */
218    public function setFlavor( string $flavor ): void {
219        if ( !in_array( $flavor, self::OUTPUT_FLAVORS ) ) {
220            throw new InvalidArgumentException( 'Invalid flavor supplied' );
221        }
222
223        if ( $this->stash ) {
224            // XXX: throw?
225            $flavor = 'stash';
226        }
227
228        $this->flavor = $flavor;
229    }
230
231    /**
232     * Returns the flavor of HTML that will be generated.
233     * @see setFlavor()
234     * @return string
235     */
236    public function getFlavor(): string {
237        return $this->flavor;
238    }
239
240    /**
241     * Set the desired Parsoid profile version for the output.
242     * The actual output version is selected to be compatible with the one given here,
243     * per the rules of semantic versioning.
244     *
245     * @note Will disable caching if the effective output version is different from the default.
246     *
247     * @param string $version
248     *
249     * @throws HttpException If the given version is not supported (status 406)
250     */
251    public function setOutputProfileVersion( $version ) {
252        $outputContentVersion = Parsoid::resolveContentVersion( $version );
253
254        if ( !$outputContentVersion ) {
255            throw new LocalizedHttpException(
256                new MessageValue( "rest-unsupported-profile-version", [ $version ] ), 406
257            );
258        }
259
260        // Only set the option if the value isn't the default!
261        if ( $outputContentVersion !== Parsoid::defaultHTMLVersion() ) {
262            throw new LocalizedHttpException(
263                new MessageValue( "rest-unsupported-profile-version", [ $version ] ), 406
264            );
265
266            // TODO: (T347426) At some later point, we may reintroduce support for
267            // non-default content versions as part of work on the content
268            // negotiation protocol.
269            //
270            // // See Parsoid::wikitext2html
271            // $this->parsoidOptions['outputContentVersion'] = $outputContentVersion;
272            // $this->isCacheable = false;
273        }
274    }
275
276    /**
277     * Determine whether stashing should be applied.
278     *
279     * @param bool $stash
280     *
281     * @return void
282     */
283    public function setStashingEnabled( bool $stash ): void {
284        $this->stash = $stash;
285
286        if ( $stash ) {
287            $this->setFlavor( 'stash' );
288        } elseif ( $this->flavor === 'stash' ) {
289            $this->setFlavor( 'view' );
290        }
291    }
292
293    /**
294     * Set the revision to render.
295     *
296     * This can take a fake RevisionRecord when rendering for previews
297     * or when switching the editor from source mode to visual mode.
298     *
299     * In that case, $revisionOrId->getId() must return 0 to indicate
300     * that the ParserCache should be bypassed. Stashing may still apply.
301     *
302     * @param RevisionRecord|int $revisionOrId
303     */
304    public function setRevision( $revisionOrId ): void {
305        Assert::parameterType( [ RevisionRecord::class, 'integer' ], $revisionOrId, '$revision' );
306
307        if ( is_int( $revisionOrId ) && $revisionOrId <= 0 ) {
308            throw new HttpError( 400, "Bad revision ID: $revisionOrId" );
309        }
310
311        $this->revisionOrId = $revisionOrId;
312
313        if ( $this->getRevisionId() === null ) {
314            // If we have a RevisionRecord but no revision ID, we are dealing with a fake
315            // revision used for editor previews or mode switches. The wikitext is coming
316            // from the request, not the database, so the result is not cacheable for re-use
317            // by others (though it can be stashed for use by the same client).
318            $this->isCacheable = false;
319        }
320    }
321
322    /**
323     * Set the content to render. Useful when rendering for previews
324     * or when switching the editor from source mode to visual mode.
325     *
326     * This will create a fake revision for rendering, the revision ID will be 0.
327     *
328     * @see setRevision
329     * @see setContentSource
330     *
331     * @param Content $content
332     */
333    public function setContent( Content $content ): void {
334        $rev = new MutableRevisionRecord( $this->page );
335        $rev->setId( 0 );
336        $rev->setPageId( $this->page->getId() );
337        $rev->setContent( SlotRecord::MAIN, $content );
338        $this->setRevision( $rev );
339    }
340
341    /**
342     * Set the content to render. Useful when rendering for previews
343     * or when switching the editor from source mode to visual mode.
344     *
345     * This will create a fake revision for rendering. The revision ID will be 0.
346     *
347     * @param string $source The source data, e.g. wikitext
348     * @param string $model The content model indicating how to interpret $source, e.g. CONTENT_MODEL_WIKITEXT
349     *
350     * @see setRevision
351     * @see setContent
352     */
353    public function setContentSource( string $source, string $model ): void {
354        try {
355            $handler = $this->contentHandlerFactory->getContentHandler( $model );
356            $content = $handler->unserializeContent( $source );
357            $this->setContent( $content );
358        } catch ( MWUnknownContentModelException ) {
359            throw new LocalizedHttpException( new MessageValue( "rest-bad-content-model", [ $model ] ), 400 );
360        }
361    }
362
363    /**
364     * This is equivalent to 'pageLanguageOverride' in PageConfigFactory
365     * For example, when clients call the REST API with the 'content-language'
366     * header to affect language variant conversion.
367     *
368     * @param Bcp47Code|string $pageLanguage the page language, as a Bcp47Code
369     *   or a BCP-47 string.
370     */
371    public function setPageLanguage( $pageLanguage ): void {
372        if ( is_string( $pageLanguage ) ) {
373            $pageLanguage = new Bcp47CodeValue( $pageLanguage );
374        }
375        $this->pageLanguage = $pageLanguage;
376    }
377
378    /**
379     * Initializes the helper with the given parameters like the page
380     * we're dealing with, parameters gotten from the request inputs,
381     * and the revision if any is available.
382     *
383     * @param PageIdentity $page
384     * @param array $parameters
385     * @param Authority $authority
386     * @param RevisionRecord|int|null $revision
387     * @deprecated since 1.43, use parameters in constructor instead
388     */
389    public function init(
390        PageIdentity $page,
391        array $parameters,
392        Authority $authority,
393        $revision = null
394    ) {
395        wfDeprecated( __METHOD__, '1.43' );
396        $this->initInternal( $page, $parameters, $authority, $revision );
397    }
398
399    /**
400     * @param PageIdentity $page
401     * @param array $parameters
402     * @param Authority $authority
403     * @param int|RevisionRecord|null $revision
404     */
405    private function initInternal(
406        PageIdentity $page,
407        array $parameters,
408        Authority $authority,
409        $revision = null
410    ) {
411        $this->page = $page;
412        $this->authority = $authority;
413        $this->stash = $parameters['stash'] ?? false;
414
415        if ( $revision !== null ) {
416            $this->setRevision( $revision );
417        }
418
419        if ( $this->stash ) {
420            $this->setFlavor( 'stash' );
421        } else {
422            $this->setFlavor( $parameters['flavor'] ?? 'view' );
423        }
424        $this->parserOptions ??= ParserOptions::newFromAnon();
425    }
426
427    /**
428     * @inheritDoc
429     */
430    public function setVariantConversionLanguage(
431        $targetLanguage,
432        $sourceLanguage = null
433    ): void {
434        if ( is_string( $targetLanguage ) ) {
435            $targetLanguage = $this->getAcceptedTargetLanguage( $targetLanguage );
436            $targetLanguage = LanguageCode::normalizeNonstandardCodeAndWarn(
437                $targetLanguage
438            );
439        }
440        if ( is_string( $sourceLanguage ) ) {
441            $sourceLanguage = LanguageCode::normalizeNonstandardCodeAndWarn(
442                $sourceLanguage
443            );
444        }
445        $this->targetLanguage = $targetLanguage;
446        $this->sourceLanguage = $sourceLanguage;
447    }
448
449    /**
450     * Get a target language from an accept header
451     */
452    public static function getAcceptedTargetLanguage( string $targetLanguage ): string {
453        // We could try to identify the most desirable language here,
454        // following the rules for Accept-Language headers in RFC9100.
455        // For now, just take the first language code.
456
457        if ( preg_match( '/^\s*([-\w]+)/', $targetLanguage, $m ) ) {
458            return $m[1];
459        } else {
460            // "undetermined" per RFC5646
461            return 'und';
462        }
463    }
464
465    /**
466     * @inheritDoc
467     */
468    public function getHtml(): ParserOutput {
469        if ( $this->processedParserOutput ) {
470            return $this->processedParserOutput;
471        }
472
473        $parserOutput = $this->getParserOutput();
474
475        if ( $this->stash ) {
476            $this->authorizeWriteOrThrow( $this->authority, 'stashbasehtml', $this->page );
477
478            $isFakeRevision = $this->getRevisionId() === null;
479            $parsoidStashKey = ParsoidRenderID::newFromParserOutput( $parserOutput );
480            $stashSuccess = $this->parsoidOutputStash->set(
481                $parsoidStashKey,
482                new SelserContext(
483                    PageBundleParserOutputConverter::pageBundleFromParserOutput( $parserOutput ),
484                    $parsoidStashKey->getRevisionID(),
485                    $isFakeRevision ? $this->revisionOrId->getContent( SlotRecord::MAIN ) : null
486                )
487            );
488            if ( !$stashSuccess ) {
489                $this->statsFactory->getCounter( 'htmloutputrendererhelper_stash_total' )
490                    ->setLabel( 'status', 'fail' )
491                    ->increment();
492
493                $errorData = [ 'parsoid-stash-key' => $parsoidStashKey ];
494                LoggerFactory::getInstance( 'HtmlOutputRendererHelper' )->error(
495                    "Parsoid stash failure",
496                    $errorData
497                );
498                throw new LocalizedHttpException(
499                    MessageValue::new( 'rest-html-stash-failure' ),
500                    500,
501                    $errorData
502                );
503            }
504            $this->statsFactory->getCounter( 'htmloutputrendererhelper_stash_total' )
505                ->setLabel( 'status', 'save' )
506                ->increment();
507        }
508
509        if ( $this->flavor === 'edit' ) {
510            $pb = $this->getPageBundle();
511
512            // Inject data-parsoid and data-mw attributes.
513            $options = [
514                'siteConfig' => $this->parsoidSiteConfig,
515            ];
516            $parserOutput->setRawText( $pb->toInlineAttributeHtml( $options ) );
517        }
518
519        // Check if variant conversion has to be performed
520        // NOTE: Variant conversion is performed on the fly, and kept outside the stash.
521        if ( $this->targetLanguage ) {
522            $languageVariantConverter = $this->htmlTransformFactory->getLanguageVariantConverter( $this->page );
523            $parserOutput = $languageVariantConverter->convertParserOutputVariant(
524                $parserOutput,
525                $this->targetLanguage,
526                $this->sourceLanguage
527            );
528        }
529
530        $this->processedParserOutput = $parserOutput;
531        return $parserOutput;
532    }
533
534    /**
535     * @inheritDoc
536     */
537    public function getETag( string $suffix = '' ): ?string {
538        $parserOutput = $this->getParserOutput();
539
540        $renderID = ParsoidRenderID::newFromParserOutput( $parserOutput )->getKey();
541
542        if ( $suffix !== '' ) {
543            $eTag = "$renderID/{$this->flavor}/$suffix";
544        } else {
545            $eTag = "$renderID/{$this->flavor}";
546        }
547
548        if ( $this->targetLanguage ) {
549            $eTag .= "+lang:{$this->targetLanguage->toBcp47Code()}";
550        }
551
552        return "\"{$eTag}\"";
553    }
554
555    private function isLatest(): bool {
556        $revId = $this->getRevisionId();
557
558        if ( $revId === null ) {
559            return false; // un-saved revision
560        }
561
562        if ( $revId === 0 ) {
563            return true; // latest revision
564        }
565
566        $page = $this->getPageRecord();
567
568        if ( !$page ) {
569            return false; // page doesn't exist. shouldn't happen.
570        }
571
572        return $revId === $page->getLatest();
573    }
574
575    /**
576     * @inheritDoc
577     */
578    public function getLastModified(): ?string {
579        if ( $this->isLatest() ) {
580            $page = $this->getPageRecord();
581
582            // $page should never be null here.
583            // If it's null, getParserOutput() will fail nicely below.
584            if ( $page ) {
585                // Using the touch timestamp for this purpose is in line with
586                // the behavior of ViewAction::show(). However,
587                // OutputPage::checkLastModified() applies a lot of additional
588                // limitations.
589                return $page->getTouched();
590            }
591        }
592
593        return $this->getParserOutput()->getCacheTime();
594    }
595
596    /**
597     * @inheritDoc
598     */
599    public static function getParamSettings(): array {
600        return [
601            'stash' => [
602                Handler::PARAM_SOURCE => 'query',
603                ParamValidator::PARAM_TYPE => 'boolean',
604                ParamValidator::PARAM_DEFAULT => false,
605                ParamValidator::PARAM_REQUIRED => false,
606                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-html-output-stash' )
607            ],
608            'flavor' => [
609                Handler::PARAM_SOURCE => 'query',
610                ParamValidator::PARAM_TYPE => self::OUTPUT_FLAVORS,
611                ParamValidator::PARAM_DEFAULT => 'view',
612                ParamValidator::PARAM_REQUIRED => false,
613                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-html-output-flavor' )
614            ],
615        ];
616    }
617
618    private function getDefaultPageLanguage(): Bcp47Code {
619        // NOTE: keep in sync with Parser::getTargetLanguage!
620
621        // XXX: Inject a TitleFactory just for this?! We need a better way to determine the page language...
622        $title = Title::castFromPageIdentity( $this->page );
623
624        if ( $this->parserOptions->getInterfaceMessage() ) {
625            return $this->parserOptions->getUserLangObj();
626        }
627
628        return $title->getPageLanguage();
629    }
630
631    private function getParserOutput(): ParserOutput {
632        if ( !$this->parserOutput ) {
633            $this->parserOptions->setRenderReason( __METHOD__ );
634
635            $defaultLanguage = $this->getDefaultPageLanguage();
636
637            if ( $this->pageLanguage
638                && $this->pageLanguage->toBcp47Code() !== $defaultLanguage->toBcp47Code()
639            ) {
640                $languageObj = $this->languageFactory->getLanguage( $this->pageLanguage );
641                $this->parserOptions->setTargetLanguage( $languageObj );
642                // Ensure target language splits the parser cache, when
643                // non-default; targetLangauge is not in
644                // ParserOptions::$cacheVaryingOptionsHash for the legacy
645                // parser.
646                $this->parserOptions->addExtraKey( 'target=' . $languageObj->getCode() );
647            }
648
649            try {
650                $status = $this->getParserOutputInternal();
651            } catch ( RevisionAccessException $e ) {
652                throw new LocalizedHttpException(
653                    MessageValue::new( 'rest-nonexistent-title' ),
654                    404,
655                    [ 'reason' => $e->getMessage() ]
656                );
657            }
658
659            if ( !$status->isOK() ) {
660                if ( $status->hasMessage( 'parsoid-client-error' ) ) {
661                    $this->throwExceptionForStatus( $status, 'rest-html-backend-error', 400 );
662                } elseif ( $status->hasMessage( 'parsoid-resource-limit-exceeded' ) ) {
663                    $this->throwExceptionForStatus( $status, 'rest-resource-limit-exceeded', 413 );
664                } elseif ( $status->hasMessage( 'missing-revision-permission' ) ) {
665                    $this->throwExceptionForStatus( $status, 'rest-permission-denied-revision', 403 );
666                } elseif ( $status->hasMessage( 'parsoid-revision-access' ) ) {
667                    $this->throwExceptionForStatus( $status, 'rest-specified-revision-unavailable', 404 );
668                } else {
669                    $this->logStatusError( $status, 'Parsoid backend error', 'HtmlOutputRendererHelper' );
670                    $this->throwExceptionForStatus( $status, 'rest-html-backend-error', 500 );
671                }
672            }
673
674            $this->parserOutput = $status->getValue();
675        }
676
677        Assert::invariant( $this->parserOutput->getRenderId() !== null, "no render id" );
678        return $this->parserOutput;
679    }
680
681    /**
682     * The content language of the HTML output after parsing.
683     *
684     * @return Bcp47Code The language, as a BCP-47 code
685     */
686    public function getHtmlOutputContentLanguage(): Bcp47Code {
687        $contentLanguage = $this->getHtml()->getLanguage();
688
689        // This shouldn't happen, but don't crash if it does:
690        if ( !$contentLanguage ) {
691            if ( $this->pageLanguage ) {
692                LoggerFactory::getInstance( 'HtmlOutputRendererHelper' )->warning(
693                    "ParserOutput does not specify a language"
694                );
695
696                $contentLanguage = $this->pageLanguage;
697            } else {
698                LoggerFactory::getInstance( 'HtmlOutputRendererHelper' )->warning(
699                    "ParserOutput does not specify a language and no page language set in helper.",
700                    // (T387453) Add a stack trace to help debug the sources of this issue
701                    [ 'fauxerror' => new RuntimeException( 'Dummy error for a trace' ) ]
702                );
703
704                $title = Title::newFromPageIdentity( $this->page );
705                $contentLanguage = $title->getPageLanguage();
706            }
707        }
708
709        return $contentLanguage;
710    }
711
712    /**
713     * @inheritDoc
714     */
715    public function putHeaders( ResponseInterface $response, bool $forHtml = true ): void {
716        if ( $forHtml ) {
717            // For HTML, we want to set the Content-Language. For JSON, we probably don't.
718            $response->setHeader( 'Content-Language', $this->getHtmlOutputContentLanguage()->toBcp47Code() );
719
720            $pb = $this->getPageBundle();
721            ParsoidFormatHelper::setContentType( $response, ParsoidFormatHelper::FORMAT_HTML, $pb->version );
722        }
723
724        if ( $this->targetLanguage ) {
725            $response->addHeader( 'Vary', 'Accept-Language' );
726        }
727
728        // XXX: if Parsoid returns Vary headers, set them here?!
729
730        if ( !$this->isCacheable ) {
731            $response->setHeader( 'Cache-Control', 'private,no-cache,s-maxage=0' );
732        }
733
734        // TODO: cache control for stable HTML? See ContentHelper::setCacheControl
735
736        if ( $this->getRevisionId() ) {
737            $response->setHeader( 'Content-Revision-Id', (string)$this->getRevisionId() );
738        }
739    }
740
741    /**
742     * Returns the rendered HTML as a HtmlPageBundle object.
743     */
744    public function getPageBundle(): HtmlPageBundle {
745        // XXX: converting between HtmlPageBundle and ParserOutput is inefficient!
746        $parserOutput = $this->getParserOutput();
747        $pb = PageBundleParserOutputConverter::pageBundleFromParserOutput( $parserOutput );
748
749        // Check if variant conversion has to be performed
750        // NOTE: Variant conversion is performed on the fly, and kept outside the stash.
751        if ( $this->targetLanguage ) {
752            $languageVariantConverter = $this->htmlTransformFactory->getLanguageVariantConverter( $this->page );
753            $pb = $languageVariantConverter->convertPageBundleVariant(
754                $pb,
755                $this->targetLanguage,
756                $this->sourceLanguage
757            );
758        }
759
760        return $pb;
761    }
762
763    /**
764     * Returns the ID of the revision that is being rendered.
765     *
766     * This will return 0 if no revision has been specified, so the current revision
767     * will be rendered.
768     *
769     * This wil return null if RevisionRecord has been set but that RevisionRecord
770     * does not have a revision ID, e.g. when rendering a preview.
771     */
772    public function getRevisionId(): ?int {
773        if ( !$this->revisionOrId ) {
774            // If we don't have a revision set, or it's 0, we are rendering the current revision.
775            return 0;
776        }
777
778        if ( $this->revisionOrId instanceof RevisionRecord ) {
779            // NOTE: return null even if getId() gave us 0
780            return $this->revisionOrId->getId() ?: null;
781        }
782
783        // It's a revision ID, just return it
784        return (int)$this->revisionOrId;
785    }
786
787    /**
788     * Strip Parsoid's section wrappers
789     *
790     * TODO: Should we move this to Parsoid's ContentUtils class?
791     * There already is a stripUnnecessaryWrappersAndSyntheticNodes but
792     * it targets html2wt and does a lot more than just section unwrapping.
793     */
794    private function stripParsoidSectionTags( DocumentFragment|Element $elt ): void {
795        $n = $elt->firstChild;
796        while ( $n ) {
797            $next = $n->nextSibling;
798            if ( $n instanceof Element ) {
799                // Recurse into subtree before stripping this
800                $this->stripParsoidSectionTags( $n );
801                // Strip <section> tags and synthetic extended-annotation-region wrappers
802                if ( WTUtils::isParsoidSectionTag( $n ) ) {
803                    $parent = $n->parentNode;
804                    // Help out phan
805                    '@phan-var Element $parent';
806                    DOMUtils::migrateChildren( $n, $parent, $n );
807                    $parent->removeChild( $n );
808                }
809            }
810            $n = $next;
811        }
812    }
813
814    /**
815     * Returns the page record, or null if no page is known or the page does not exist.
816     *
817     * @return PageRecord|null
818     */
819    private function getPageRecord(): ?PageRecord {
820        if ( $this->page === null ) {
821            return null;
822        }
823
824        if ( !$this->page instanceof PageRecord ) {
825            $page = $this->pageLookup->getPageByReference( $this->page );
826            if ( !$page ) {
827                return null;
828            }
829
830            $this->page = $page;
831        }
832
833        return $this->page;
834    }
835
836    private function getParserOutputInternal(): Status {
837        // NOTE: ParserOutputAccess::getParserOutput() should be used for revisions
838        //       that come from the database. Either this revision is null to indicate
839        //       the current revision or the revision must have an ID.
840        // If we have a revision and the ID is 0 or null, then it's a fake revision
841        // representing a preview.
842        $parsoidOptions = $this->parsoidOptions;
843        // NOTE: VisualEditor would set this flavor when transforming from Wikitext to HTML
844        //       for the purpose of editing when doing parsefragment (in body only mode).
845        if ( $this->flavor === 'fragment' || $this->getRevisionId() === null ) {
846            $this->isCacheable = false;
847        }
848
849        $parserOutputAccessOptions = [
850            // Use pool counter (T387478).
851            // Use it even if the output is not cacheable, to protect against
852            // load spikes.
853            ParserOutputAccess::OPT_POOL_COUNTER =>
854                ParserOutputAccess::POOL_COUNTER_REST_API
855        ];
856
857        // Find page
858        $pageRecord = $this->getPageRecord();
859        $revision = $this->revisionOrId;
860
861        // NOTE: If we have a RevisionRecord already and this is
862        //       not cacheable, just use it, there is no need to
863        //       resolve $page to a PageRecord (and it may not be
864        //       possible if the page doesn't exist).
865        if ( $this->isCacheable ) {
866            if ( !$pageRecord ) {
867                if ( $this->page ) {
868                    throw new RevisionAccessException(
869                        'Page {name} not found',
870                        [ 'name' => "{$this->page}" ]
871                    );
872                } else {
873                    throw new RevisionAccessException( "No page" );
874                }
875            }
876
877            $revision ??= $pageRecord->getLatest();
878
879            if ( is_int( $revision ) ) {
880                $revId = $revision;
881                $revision = $this->revisionLookup->getRevisionById( $revId );
882
883                if ( !$revision ) {
884                    throw new RevisionAccessException(
885                        'Revision {revId} not found',
886                        [ 'revId' => $revId ]
887                    );
888                }
889            }
890
891            if ( $pageRecord->getId() !== $revision->getPageId() ) {
892                if ( $this->lenientRevHandling ) {
893                    $pageRecord = $this->pageLookup->getPageById( $revision->getPageId() );
894                    if ( !$pageRecord ) {
895                        // This should ideally never trigger!
896                        throw new \RuntimeException(
897                            "Unexpected NULL page for pageid " . $revision->getPageId() .
898                            " from revision " . $revision->getId()
899                        );
900                    }
901                    // Don't cache this!
902                    $parserOutputAccessOptions[ ParserOutputAccess::OPT_NO_UPDATE_CACHE ] = true;
903                } else {
904                    throw new RevisionAccessException(
905                        'Revision {revId} does not belong to page {name}',
906                        [ 'name' => $pageRecord->getDBkey(), 'revId' => $revision->getId() ]
907                    );
908                }
909            }
910        }
911
912        $contentModel = $revision->getMainContentModel();
913        if ( $this->parsoidSiteConfig->supportsContentModel( $contentModel ) ) {
914            $this->parserOptions->setUseParsoid();
915        }
916        if ( $this->isCacheable ) {
917            // phan can't tell that we must have used the block above to
918            // resolve $pageRecord to a PageRecord if we've made it to this block.
919            '@phan-var PageRecord $pageRecord';
920            try {
921                $status = $this->parserOutputAccess->getParserOutput(
922                    $pageRecord, $this->parserOptions, $revision, $parserOutputAccessOptions
923                );
924            } catch ( ClientError $e ) {
925                $status = Status::newFatal( 'parsoid-client-error', $e->getMessage() );
926            } catch ( ResourceLimitExceededException $e ) {
927                $status = Status::newFatal( 'parsoid-resource-limit-exceeded', $e->getMessage() );
928            }
929            Assert::invariant( $status->isOK() ? $status->getValue()->getRenderId() !== null : true, "no render id" );
930        } else {
931            '@phan-var RevisionRecord $revision';
932            $status = $this->parseUncacheable(
933                $this->page,
934                $revision,
935                $this->lenientRevHandling
936            );
937
938            // @phan-suppress-next-line PhanSuspiciousValueComparison
939            if ( $status->isOK() && $this->flavor === 'fragment' ) {
940                // Unwrap sections and return body_only content
941                // NOTE: This introduces an extra html -> dom -> html roundtrip
942                // This will get addressed once HtmlHolder work is complete
943                $parserOutput = $status->getValue();
944                $body = $parserOutput->getContentHolder()->getAsDom(
945                    ContentHolder::BODY_FRAGMENT
946                );
947                $this->stripParsoidSectionTags( $body );
948            }
949            Assert::invariant( $status->isOK() ? $status->getValue()->getRenderId() !== null : true, "no render id" );
950        }
951
952        return $status;
953    }
954
955    // See ParserOutputAccess::renderRevision() -- but of course this method
956    // bypasses any caching.
957    private function parseUncacheable(
958        PageIdentity $page,
959        RevisionRecord $revision,
960        bool $lenientRevHandling = false
961    ): Status {
962        // Enforce caller expectation
963        $revId = $revision->getId();
964        if ( $revId !== 0 && $revId !== null ) {
965            return Status::newFatal( 'parsoid-revision-access',
966                "parseUncacheable should not be called for a real revision" );
967        }
968        try {
969            $renderedRev = $this->revisionRenderer->getRenderedRevision(
970                $revision,
971                $this->parserOptions,
972                // ParserOutputAccess uses 'null' for the authority and
973                // 'audience' => RevisionRecord::RAW, presumably because
974                // the access checks are already handled by the
975                // RestAuthorizeTrait
976                $this->authority,
977                [ 'audience' => RevisionRecord::RAW ]
978            );
979            if ( $renderedRev === null ) {
980                return Status::newFatal( 'parsoid-revision-access' );
981            }
982            $parserOutput = $renderedRev->getRevisionParserOutput();
983            // Ensure this isn't accidentally cached
984            $parserOutput->updateCacheExpiry( 0 );
985            return Status::newGood( $parserOutput );
986        } catch ( ClientError $e ) {
987            return Status::newFatal( 'parsoid-client-error', $e->getMessage() );
988        } catch ( ResourceLimitExceededException $e ) {
989            return Status::newFatal( 'parsoid-resource-limit-exceeded', $e->getMessage() );
990        }
991    }
992
993    public function isParsoidContent(): bool {
994        return PageBundleParserOutputConverter::hasPageBundle(
995            $this->getParserOutput()
996        );
997    }
998
999}