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